From 3e211e938bc4c110fd4ab21e38f2b3f133a546ee Mon Sep 17 00:00:00 2001 From: John Schulz Date: Mon, 9 Nov 2020 05:42:45 -0500 Subject: [PATCH 1/9] [Ingest Manager] Unify install* under installPackage (#82916) ## Summary * Add `installPackage` with `installSource` param, to provide a single interface the `install*` functions. ```diff - const res = await installPackageFromRegistry({ + const res = await installPackage({ + installSource: 'registry', ``` and ```diff - const res = await installPackageByUpload({ + const res = await installPackage({ + installSource: 'upload', ``` * Push some repeated work (`install`, `removable`) from `install*` into `_installPackage`. Which also simplifies its interface. ### installPackage For now `installPackage` checks the `installSource` and calls the same `install*` functions to prevent any change in behavior but there's still a lot of overlap between `installPackageFromRegistry` & `installPackageByUpload`. I think we can bring them together into `installPackage` using the same branching on `installSource`. ### local checks with curl
curl request/responses for happy path: ``` ## zip: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"response":[{"id":"apache-Logs-Apache-Dashboard-ecs","type":"dashboard"},{"id":"apache-Metrics-Apache-HTTPD-server-status-ecs","type":"dashboard"},{"id":"Apache-HTTPD-CPU-ecs","type":"visualization"},{"id":"Apache-HTTPD-Hostname-list-ecs","type":"visualization"},{"id":"Apache-HTTPD-Load1-slash-5-slash-15-ecs","type":"visualization"},{"id":"Apache-HTTPD-Scoreboard-ecs","type":"visualization"},{"id":"Apache-HTTPD-Total-accesses-and-kbytes-ecs","type":"visualization"},{"id":"Apache-HTTPD-Uptime-ecs","type":"visualization"},{"id":"Apache-HTTPD-Workers-ecs","type":"visualization"},{"id":"Apache-access-unique-IPs-map-ecs","type":"visualization"},{"id":"Apache-browsers-ecs","type":"visualization"},{"id":"Apache-error-logs-over-time-ecs","type":"visualization"},{"id":"Apache-operating-systems-ecs","type":"visualization"},{"id":"Apache-response-codes-of-top-URLs-ecs","type":"visualization"},{"id":"Apache-response-codes-over-time-ecs","type":"visualization"},{"id":"Apache-HTTPD-ecs","type":"search"},{"id":"Apache-access-logs-ecs","type":"search"},{"id":"Apache-errors-log-ecs","type":"search"}]} ## Uploaded packages can be deleted as expected: curl -X DELETE -u elastic:changeme http://localhost:5601/api/fleet/epm/packages/apache-0.1.4 -H 'kbn-xsrf: xxx' {"response":[{"id":"apache-Logs-Apache-Dashboard-ecs","type":"dashboard"},{"id":"apache-Metrics-Apache-HTTPD-server-status-ecs","type":"dashboard"},{"id":"Apache-HTTPD-CPU-ecs","type":"visualization"},{"id":"Apache-HTTPD-Hostname-list-ecs","type":"visualization"},{"id":"Apache-HTTPD-Load1-slash-5-slash-15-ecs","type":"visualization"},{"id":"Apache-HTTPD-Scoreboard-ecs","type":"visualization"},{"id":"Apache-HTTPD-Total-accesses-and-kbytes-ecs","type":"visualization"},{"id":"Apache-HTTPD-Uptime-ecs","type":"visualization"},{"id":"Apache-HTTPD-Workers-ecs","type":"visualization"},{"id":"Apache-access-unique-IPs-map-ecs","type":"visualization"},{"id":"Apache-browsers-ecs","type":"visualization"},{"id":"Apache-error-logs-over-time-ecs","type":"visualization"},{"id":"Apache-operating-systems-ecs","type":"visualization"},{"id":"Apache-response-codes-of-top-URLs-ecs","type":"visualization"},{"id":"Apache-response-codes-over-time-ecs","type":"visualization"},{"id":"Apache-HTTPD-ecs","type":"search"},{"id":"Apache-access-logs-ecs","type":"search"},{"id":"Apache-errors-log-ecs","type":"search"}]} ## Now upload curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz -H 'kbn-xsrf: xyz' -H 'Content-Type: application/gzip' {"response":[{"id":"apache-Metrics-Apache-HTTPD-server-status-ecs","type":"dashboard"},{"id":"apache-Logs-Apache-Dashboard-ecs","type":"dashboard"},{"id":"Apache-access-unique-IPs-map-ecs","type":"visualization"},{"id":"Apache-HTTPD-CPU-ecs","type":"visualization"},{"id":"Apache-HTTPD-Load1-slash-5-slash-15-ecs","type":"visualization"},{"id":"Apache-response-codes-over-time-ecs","type":"visualization"},{"id":"Apache-HTTPD-Workers-ecs","type":"visualization"},{"id":"Apache-HTTPD-Hostname-list-ecs","type":"visualization"},{"id":"Apache-error-logs-over-time-ecs","type":"visualization"},{"id":"Apache-HTTPD-Scoreboard-ecs","type":"visualization"},{"id":"Apache-HTTPD-Uptime-ecs","type":"visualization"},{"id":"Apache-operating-systems-ecs","type":"visualization"},{"id":"Apache-HTTPD-Total-accesses-and-kbytes-ecs","type":"visualization"},{"id":"Apache-browsers-ecs","type":"visualization"},{"id":"Apache-response-codes-of-top-URLs-ecs","type":"visualization"},{"id":"Apache-access-logs-ecs","type":"search"},{"id":"Apache-errors-log-ecs","type":"search"},{"id":"Apache-HTTPD-ecs","type":"search"},{"id":"logs-apache.error-0.1.4","type":"ingest_pipeline"},{"id":"logs-apache.access-0.1.4","type":"ingest_pipeline"},{"id":"logs-apache.error","type":"index_template"},{"id":"metrics-apache.status","type":"index_template"},{"id":"logs-apache.access","type":"index_template"}]} ```
curl request/responses for archive errors: ``` ## Wrong content type: ### tar.gz with application/zip: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Error during extraction of package: Error: end of central directory record signature not found. Assumed content type was application/zip, check if this matches the archive type."} ### zip with application/gzip: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/gzip' {"statusCode":400,"error":"Bad Request","message":"Archive seems empty. Assumed content type was application/gzip, check if this matches the archive type."} ## Invalid packages ### Two top-level directories: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_two_toplevels_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Package contains more than one top-level directory."} ### No manifest: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_no_manifest_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Package must contain a top-level manifest.yml file."} ### Invalid YAML in manifest: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_invalid_yaml_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Could not parse top-level package manifest: YAMLException: bad indentation of a mapping entry at line 2, column 7:\n name: apache\n ^."} ### Mandatory field missing in manifest: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_missing_field_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Invalid top-level package manifest: one or more fields missing of name, version, description, type, categories, format_version"} ### Top-level directory doesn't match name and version from manifest: curl -X POST -u elastic:changeme http://localhost:5601/api/fleet/epm/packages --data-binary @$KIBANA_HOME/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_toplevel_mismatch_0.1.4.zip -H 'kbn-xsrf: xyz' -H 'Content-Type: application/zip' {"statusCode":400,"error":"Bad Request","message":"Name thisIsATypo and version 0.1.4 do not match top-level directory apache-0.1.4"} ```
#### TS type check examples on `installPackage`
screenshots Screen Shot 2020-11-08 at 4 00 14 PM Screen Shot 2020-11-08 at 4 00 21 PM Screen Shot 2020-11-08 at 4 01 06 PM Screen Shot 2020-11-08 at 4 01 25 PM Screen Shot 2020-11-08 at 4 02 54 PM
### Checklist - [ ] [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 --- .../server/routes/epm/handlers.ts | 9 +- .../epm/packages/_install_package.test.ts | 4 - .../services/epm/packages/_install_package.ts | 11 +-- .../server/services/epm/packages/index.ts | 3 +- .../server/services/epm/packages/install.ts | 92 ++++++++++++------- 5 files changed, 68 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index 198a54ca84125..1d221b8b1eead 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -35,8 +35,7 @@ import { getPackageInfo, handleInstallPackageFailure, isBulkInstallError, - installPackageFromRegistry, - installPackageByUpload, + installPackage, removeInstallation, getLimitedPackages, getInstallationObject, @@ -149,7 +148,8 @@ export const installPackageFromRegistryHandler: RequestHandler< const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); try { - const res = await installPackageFromRegistry({ + const res = await installPackage({ + installSource: 'registry', savedObjectsClient, pkgkey, callCluster, @@ -224,7 +224,8 @@ export const installPackageByUploadHandler: RequestHandler< const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later const archiveBuffer = Buffer.from(request.body); try { - const res = await installPackageByUpload({ + const res = await installPackage({ + installSource: 'upload', savedObjectsClient, callCluster, archiveBuffer, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts index 5d3e8e9ce87d1..b7650d10b6b25 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts @@ -61,11 +61,7 @@ describe('_installPackage', () => { const installationPromise = _installPackage({ savedObjectsClient: soClient, callCluster, - pkgName: 'abc', - pkgVersion: '1.2.3', paths: [], - removable: false, - internal: false, packageInfo: { name: 'xyz', version: '4.5.6', diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts index f570984cc61aa..a83d9428b7c93 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts @@ -21,6 +21,7 @@ import { installPipelines, deletePreviousPipelines } from '../elasticsearch/inge import { installILMPolicy } from '../elasticsearch/ilm/install'; import { installKibanaAssets, getKibanaAssets } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; +import { isRequiredPackage } from './index'; import { deleteKibanaSavedObjectsAssets } from './remove'; import { installTransform } from '../elasticsearch/transform/install'; import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; @@ -32,28 +33,22 @@ import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './insta export async function _installPackage({ savedObjectsClient, callCluster, - pkgName, - pkgVersion, installedPkg, paths, - removable, - internal, packageInfo, installType, installSource, }: { savedObjectsClient: SavedObjectsClientContract; callCluster: CallESAsCurrentUser; - pkgName: string; - pkgVersion: string; installedPkg?: SavedObject; paths: string[]; - removable: boolean; - internal: boolean; packageInfo: InstallablePackage; installType: InstallType; installSource: InstallSource; }): Promise { + const { internal = false, name: pkgName, version: pkgVersion } = packageInfo; + const removable = !isRequiredPackage(pkgName); const toSaveESIndexPatterns = generateESIndexPatterns(packageInfo.data_streams); // add the package installation to the saved object. // if some installation already exists, just update install info diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index 410a9c0b22537..a1128011d81e6 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -29,8 +29,7 @@ export { BulkInstallResponse, IBulkInstallPackageError, handleInstallPackageFailure, - installPackageFromRegistry, - installPackageByUpload, + installPackage, ensureInstalledPackage, } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index e7d8c8d4695d4..00a5c689e906d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -24,7 +24,6 @@ import * as Registry from '../registry'; import { getInstallation, getInstallationObject, - isRequiredPackage, bulkInstallPackages, isBulkInstallError, } from './index'; @@ -52,7 +51,7 @@ export async function installLatestPackage(options: { name: latestPackage.name, version: latestPackage.version, }); - return installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); + return installPackage({ installSource: 'registry', savedObjectsClient, pkgkey, callCluster }); } catch (err) { throw err; } @@ -148,7 +147,8 @@ export async function handleInstallPackageFailure({ } const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackageFromRegistry({ + await installPackage({ + installSource: 'registry', savedObjectsClient, pkgkey: prevVersion, callCluster, @@ -186,7 +186,12 @@ export async function upgradePackage({ }); try { - const assets = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); + const assets = await installPackage({ + installSource: 'registry', + savedObjectsClient, + pkgkey, + callCluster, + }); return { name: pkgToUpgrade, newVersion: latestPkg.version, @@ -218,19 +223,19 @@ export async function upgradePackage({ } } -interface InstallPackageParams { +interface InstallRegistryPackageParams { savedObjectsClient: SavedObjectsClientContract; pkgkey: string; callCluster: CallESAsCurrentUser; force?: boolean; } -export async function installPackageFromRegistry({ +async function installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, force = false, -}: InstallPackageParams): Promise { +}: InstallRegistryPackageParams): Promise { // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); // TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge @@ -250,37 +255,36 @@ export async function installPackageFromRegistry({ const { paths, registryPackageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); - const removable = !isRequiredPackage(pkgName); - const { internal = false } = registryPackageInfo; - const installSource = 'registry'; - return _installPackage({ savedObjectsClient, callCluster, - pkgName, - pkgVersion, installedPkg, paths, - removable, - internal, packageInfo: registryPackageInfo, installType, - installSource, + installSource: 'registry', }); } -export async function installPackageByUpload({ - savedObjectsClient, - callCluster, - archiveBuffer, - contentType, -}: { +interface InstallUploadedArchiveParams { savedObjectsClient: SavedObjectsClientContract; callCluster: CallESAsCurrentUser; archiveBuffer: Buffer; contentType: string; -}): Promise { +} + +export type InstallPackageParams = + | ({ installSource: Extract } & InstallRegistryPackageParams) + | ({ installSource: Extract } & InstallUploadedArchiveParams); + +async function installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, +}: InstallUploadedArchiveParams): Promise { const { paths, archivePackageInfo } = await loadArchivePackage({ archiveBuffer, contentType }); + const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName: archivePackageInfo.name, @@ -292,25 +296,45 @@ export async function installPackageByUpload({ ); } - const removable = !isRequiredPackage(archivePackageInfo.name); - const { internal = false } = archivePackageInfo; - const installSource = 'upload'; - return _installPackage({ savedObjectsClient, callCluster, - pkgName: archivePackageInfo.name, - pkgVersion: archivePackageInfo.version, installedPkg, paths, - removable, - internal, packageInfo: archivePackageInfo, installType, - installSource, + installSource: 'upload', }); } +export async function installPackage(args: InstallPackageParams) { + if (!('installSource' in args)) { + throw new Error('installSource is required'); + } + + if (args.installSource === 'registry') { + const { savedObjectsClient, pkgkey, callCluster, force } = args; + + return installPackageFromRegistry({ + savedObjectsClient, + pkgkey, + callCluster, + force, + }); + } else if (args.installSource === 'upload') { + const { savedObjectsClient, callCluster, archiveBuffer, contentType } = args; + + return installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, + }); + } + // @ts-expect-error s/b impossibe b/c `never` by this point, but just in case + throw new Error(`Unknown installSource: ${args.installSource}`); +} + export const updateVersion = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, @@ -421,7 +445,9 @@ export async function ensurePackagesCompletedInstall( const pkgkey = `${pkg.attributes.name}-${pkg.attributes.install_version}`; // reinstall package if (elapsedTime > MAX_TIME_COMPLETE_INSTALL) { - acc.push(installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster })); + acc.push( + installPackage({ installSource: 'registry', savedObjectsClient, pkgkey, callCluster }) + ); } return acc; }, []); From fdc18392ad2c70b92fbfb4f69e1af892c020efab Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 9 Nov 2020 11:51:14 +0100 Subject: [PATCH 2/9] SavedObjects search_dsl: add match_phrase_prefix clauses when using prefix search (#82693) * add match_phrase_prefix clauses when using prefix search * add FTR tests --- .../lib/search_dsl/query_params.test.ts | 473 ++++++++++++------ .../service/lib/search_dsl/query_params.ts | 189 +++++-- .../service/lib/search_dsl/search_dsl.test.ts | 1 - .../service/lib/search_dsl/search_dsl.ts | 1 - .../apis/saved_objects/find.js | 64 +++ .../saved_objects/find_edgecases/data.json | 93 ++++ .../find_edgecases/mappings.json | 267 ++++++++++ 7 files changed, 883 insertions(+), 205 deletions(-) create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 333f5caf72525..a8c5df8d64630 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -21,28 +21,64 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; -import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import { SavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING } from '../utils'; import { getQueryParams, getClauseForReference } from './query_params'; -const registry = typeRegistryMock.create(); +const registerTypes = (registry: SavedObjectTypeRegistry) => { + registry.registerType({ + name: 'pending', + hidden: false, + namespaceType: 'single', + mappings: { + properties: { title: { type: 'text' } }, + }, + management: { + defaultSearchField: 'title', + }, + }); -const MAPPINGS = { - properties: { - pending: { properties: { title: { type: 'text' } } }, - saved: { + registry.registerType({ + name: 'saved', + hidden: false, + namespaceType: 'single', + mappings: { properties: { title: { type: 'text', fields: { raw: { type: 'keyword' } } }, obj: { properties: { key1: { type: 'text' } } }, }, }, - // mock registry returns isMultiNamespace=true for 'shared' type - shared: { properties: { name: { type: 'keyword' } } }, - // mock registry returns isNamespaceAgnostic=true for 'global' type - global: { properties: { name: { type: 'keyword' } } }, - }, + management: { + defaultSearchField: 'title', + }, + }); + + registry.registerType({ + name: 'shared', + hidden: false, + namespaceType: 'multiple', + mappings: { + properties: { name: { type: 'keyword' } }, + }, + management: { + defaultSearchField: 'name', + }, + }); + + registry.registerType({ + name: 'global', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { name: { type: 'keyword' } }, + }, + management: { + defaultSearchField: 'name', + }, + }); }; -const ALL_TYPES = Object.keys(MAPPINGS.properties); + +const ALL_TYPES = ['pending', 'saved', 'shared', 'global']; // get all possible subsets (combination) of all types const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( (subsets, value) => subsets.concat(subsets.map((set) => [...set, value])), @@ -51,48 +87,53 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( .filter((x) => x.length) // exclude empty set .map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it -const createTypeClause = (type: string, namespaces?: string[]) => { - if (registry.isMultiNamespace(type)) { - const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; - return { - bool: { - must: expect.arrayContaining([{ terms: { namespaces: array } }]), - must_not: [{ exists: { field: 'namespace' } }], - }, - }; - } else if (registry.isSingleNamespace(type)) { - const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; - const should: any = []; - if (nonDefaultNamespaces.length > 0) { - should.push({ terms: { namespace: nonDefaultNamespaces } }); - } - if (namespaces?.includes('default')) { - should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); - } - return { - bool: { - must: [{ term: { type } }], - should: expect.arrayContaining(should), - minimum_should_match: 1, - must_not: [{ exists: { field: 'namespaces' } }], - }, - }; - } - // isNamespaceAgnostic - return { - bool: expect.objectContaining({ - must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], - }), - }; -}; - /** * Note: these tests cases are defined in the order they appear in the source code, for readability's sake */ describe('#getQueryParams', () => { - const mappings = MAPPINGS; + let registry: SavedObjectTypeRegistry; type Result = ReturnType; + beforeEach(() => { + registry = new SavedObjectTypeRegistry(); + registerTypes(registry); + }); + + const createTypeClause = (type: string, namespaces?: string[]) => { + if (registry.isMultiNamespace(type)) { + const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; + return { + bool: { + must: expect.arrayContaining([{ terms: { namespaces: array } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (registry.isSingleNamespace(type)) { + const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; + const should: any = []; + if (nonDefaultNamespaces.length > 0) { + should.push({ terms: { namespace: nonDefaultNamespaces } }); + } + if (namespaces?.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + return { + bool: { + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; + }; + describe('kueryNode filter clause', () => { const expectResult = (result: Result, expected: any) => { expect(result.query.bool.filter).toEqual(expect.arrayContaining([expected])); @@ -100,13 +141,13 @@ describe('#getQueryParams', () => { describe('`kueryNode` parameter', () => { it('does not include the clause when `kueryNode` is not specified', () => { - const result = getQueryParams({ mappings, registry, kueryNode: undefined }); + const result = getQueryParams({ registry, kueryNode: undefined }); expect(result.query.bool.filter).toHaveLength(1); }); it('includes the specified Kuery clause', () => { const test = (kueryNode: KueryNode) => { - const result = getQueryParams({ mappings, registry, kueryNode }); + const result = getQueryParams({ registry, kueryNode }); const expected = esKuery.toElasticsearchQuery(kueryNode); expect(result.query.bool.filter).toHaveLength(2); expectResult(result, expected); @@ -165,7 +206,6 @@ describe('#getQueryParams', () => { it('does not include the clause when `hasReference` is not specified', () => { const result = getQueryParams({ - mappings, registry, hasReference: undefined, }); @@ -176,7 +216,6 @@ describe('#getQueryParams', () => { it('creates a should clause for specified reference when operator is `OR`', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'OR', @@ -192,7 +231,6 @@ describe('#getQueryParams', () => { it('creates a must clause for specified reference when operator is `AND`', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'AND', @@ -210,7 +248,6 @@ describe('#getQueryParams', () => { { id: 'hello', type: 'dolly' }, ]; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'OR', @@ -229,7 +266,6 @@ describe('#getQueryParams', () => { { id: 'hello', type: 'dolly' }, ]; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'AND', @@ -244,7 +280,6 @@ describe('#getQueryParams', () => { it('defaults to `OR` when operator is not specified', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, }); @@ -278,14 +313,13 @@ describe('#getQueryParams', () => { }; it('searches for all known types when `type` is not specified', () => { - const result = getQueryParams({ mappings, registry, type: undefined }); + const result = getQueryParams({ registry, type: undefined }); expectResult(result, ...ALL_TYPES); }); it('searches for specified type/s', () => { const test = (typeOrTypes: string | string[]) => { const result = getQueryParams({ - mappings, registry, type: typeOrTypes, }); @@ -309,18 +343,17 @@ describe('#getQueryParams', () => { const test = (namespaces?: string[]) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { - const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces }); + const result = getQueryParams({ registry, type: typeOrTypes, namespaces }); const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; expectResult(result, ...types.map((x) => createTypeClause(x, namespaces))); } // also test with no specified type/s - const result = getQueryParams({ mappings, registry, type: undefined, namespaces }); + const result = getQueryParams({ registry, type: undefined, namespaces }); expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces))); }; it('normalizes and deduplicates provided namespaces', () => { const result = getQueryParams({ - mappings, registry, search: '*', namespaces: ['foo', '*', 'foo', 'bar', 'default'], @@ -360,7 +393,6 @@ describe('#getQueryParams', () => { it('supersedes `type` and `namespaces` parameters', () => { const result = getQueryParams({ - mappings, registry, type: ['pending', 'saved', 'shared', 'global'], namespaces: ['foo', 'bar', 'default'], @@ -381,148 +413,266 @@ describe('#getQueryParams', () => { }); }); - describe('search clause (query.bool.must.simple_query_string)', () => { - const search = 'foo*'; + describe('search clause (query.bool)', () => { + describe('when using simple search (query.bool.must.simple_query_string)', () => { + const search = 'foo'; - const expectResult = (result: Result, sqsClause: any) => { - expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); - }; + const expectResult = (result: Result, sqsClause: any) => { + expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); + }; - describe('`search` parameter', () => { - it('does not include clause when `search` is not specified', () => { - const result = getQueryParams({ - mappings, - registry, - search: undefined, + describe('`search` parameter', () => { + it('does not include clause when `search` is not specified', () => { + const result = getQueryParams({ + registry, + search: undefined, + }); + expect(result.query.bool.must).toBeUndefined(); }); - expect(result.query.bool.must).toBeUndefined(); - }); - it('creates a clause with query for specified search', () => { - const result = getQueryParams({ - mappings, - registry, - search, + it('creates a clause with query for specified search', () => { + const result = getQueryParams({ + registry, + search, + }); + expectResult(result, expect.objectContaining({ query: search })); }); - expectResult(result, expect.objectContaining({ query: search })); }); - }); - describe('`searchFields` and `rootSearchFields` parameters', () => { - const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { - const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); - }; + describe('`searchFields` and `rootSearchFields` parameters', () => { + const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); + }; - const test = ({ - searchFields, - rootSearchFields, - }: { - searchFields?: string[]; - rootSearchFields?: string[]; - }) => { - for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const test = ({ + searchFields, + rootSearchFields, + }: { + searchFields?: string[]; + rootSearchFields?: string[]; + }) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ + registry, + type: typeOrTypes, + search, + searchFields, + rootSearchFields, + }); + let fields = rootSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + } + expectResult(result, expect.objectContaining({ fields })); + } + // also test with no specified type/s const result = getQueryParams({ - mappings, registry, - type: typeOrTypes, + type: undefined, search, searchFields, rootSearchFields, }); let fields = rootSearchFields || []; if (searchFields) { - fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); } expectResult(result, expect.objectContaining({ fields })); - } - // also test with no specified type/s - const result = getQueryParams({ - mappings, - registry, - type: undefined, - search, - searchFields, - rootSearchFields, + }; + + it('throws an error if a raw search field contains a "." character', () => { + expect(() => + getQueryParams({ + registry, + type: undefined, + search, + searchFields: undefined, + rootSearchFields: ['foo', 'bar.baz'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` + ); }); - let fields = rootSearchFields || []; - if (searchFields) { - fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); - } - expectResult(result, expect.objectContaining({ fields })); - }; - it('throws an error if a raw search field contains a "." character', () => { - expect(() => - getQueryParams({ - mappings, + it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { + const result = getQueryParams({ registry, - type: undefined, search, searchFields: undefined, - rootSearchFields: ['foo', 'bar.baz'], - }) - ).toThrowErrorMatchingInlineSnapshot( - `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` - ); + rootSearchFields: undefined, + }); + expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); + }); + + it('includes specified search fields for appropriate type/s', () => { + test({ searchFields: ['title'] }); + }); + + it('supports boosting', () => { + test({ searchFields: ['title^3'] }); + }); + + it('supports multiple search fields', () => { + test({ searchFields: ['title, title.raw'] }); + }); + + it('includes specified raw search fields', () => { + test({ rootSearchFields: ['_id'] }); + }); + + it('supports multiple raw search fields', () => { + test({ rootSearchFields: ['_id', 'originId'] }); + }); + + it('supports search fields and raw search fields', () => { + test({ searchFields: ['title'], rootSearchFields: ['_id'] }); + }); }); - it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { - const result = getQueryParams({ - mappings, + describe('`defaultSearchOperator` parameter', () => { + it('does not include default_operator when `defaultSearchOperator` is not specified', () => { + const result = getQueryParams({ + registry, + search, + defaultSearchOperator: undefined, + }); + expectResult( + result, + expect.not.objectContaining({ default_operator: expect.anything() }) + ); + }); + + it('includes specified default operator', () => { + const defaultSearchOperator = 'AND'; + const result = getQueryParams({ + registry, + search, + defaultSearchOperator, + }); + expectResult( + result, + expect.objectContaining({ default_operator: defaultSearchOperator }) + ); + }); + }); + }); + + describe('when using prefix search (query.bool.should)', () => { + const searchQuery = 'foo*'; + + const getQueryParamForSearch = ({ + search, + searchFields, + type, + }: { + search?: string; + searchFields?: string[]; + type?: string[]; + }) => + getQueryParams({ registry, search, - searchFields: undefined, - rootSearchFields: undefined, + searchFields, + type, }); - expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); - }); - it('includes specified search fields for appropriate type/s', () => { - test({ searchFields: ['title'] }); - }); + it('uses a `should` clause instead of `must`', () => { + const result = getQueryParamForSearch({ search: searchQuery, searchFields: ['title'] }); - it('supports boosting', () => { - test({ searchFields: ['title^3'] }); + expect(result.query.bool.must).toBeUndefined(); + expect(result.query.bool.should).toEqual(expect.any(Array)); + expect(result.query.bool.should.length).toBeGreaterThanOrEqual(1); + expect(result.query.bool.minimum_should_match).toBe(1); }); - - it('supports multiple search fields', () => { - test({ searchFields: ['title, title.raw'] }); + it('includes the `simple_query_string` in the `should` clauses', () => { + const result = getQueryParamForSearch({ search: searchQuery, searchFields: ['title'] }); + expect(result.query.bool.should[0]).toEqual({ + simple_query_string: expect.objectContaining({ + query: searchQuery, + }), + }); }); - it('includes specified raw search fields', () => { - test({ rootSearchFields: ['_id'] }); + it('adds a should clause for each `searchFields` / `type` tuple', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title', 'desc'], + type: ['saved', 'pending'], + }); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(5); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['saved.title', 'pending.title', 'saved.desc', 'pending.desc']); }); - it('supports multiple raw search fields', () => { - test({ rootSearchFields: ['_id', 'originId'] }); + it('uses all registered types when `type` is not provided', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title'], + type: undefined, + }); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(5); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['pending.title', 'saved.title', 'shared.title', 'global.title']); }); - it('supports search fields and raw search fields', () => { - test({ searchFields: ['title'], rootSearchFields: ['_id'] }); + it('removes the prefix search wildcard from the query', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title'], + type: ['saved'], + }); + const shouldClauses = result.query.bool.should; + const mppClauses = shouldClauses.slice(1); + + expect(mppClauses[0].match_phrase_prefix['saved.title'].query).toEqual('foo'); }); - }); - describe('`defaultSearchOperator` parameter', () => { - it('does not include default_operator when `defaultSearchOperator` is not specified', () => { - const result = getQueryParams({ - mappings, - registry, - search, - defaultSearchOperator: undefined, + it("defaults to the type's default search field when `searchFields` is not specified", () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: undefined, + type: ['saved', 'global'], }); - expectResult(result, expect.not.objectContaining({ default_operator: expect.anything() })); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(3); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['saved.title', 'global.name']); }); - it('includes specified default operator', () => { - const defaultSearchOperator = 'AND'; - const result = getQueryParams({ - mappings, - registry, - search, - defaultSearchOperator, + it('supports boosting', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title^3', 'description'], + type: ['saved'], }); - expectResult(result, expect.objectContaining({ default_operator: defaultSearchOperator })); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(3); + + const mppClauses = shouldClauses.slice(1); + + expect(mppClauses.map((clause: any) => clause.match_phrase_prefix)).toEqual([ + { 'saved.title': { query: 'foo', boost: 3 } }, + { 'saved.description': { query: 'foo', boost: 1 } }, + ]); }); }); }); @@ -532,7 +682,6 @@ describe('#getQueryParams', () => { it(`throws for ${type} when namespaces is an empty array`, () => { expect(() => getQueryParams({ - mappings, registry, namespaces: [], }) diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 8d4fe13b9bede..f73777c4f454f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -20,7 +20,6 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; -import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; @@ -28,22 +27,17 @@ import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; * Gets the types based on the type. Uses mappings to support * null type (all types), a single type string or an array */ -function getTypes(mappings: IndexMapping, type?: string | string[]) { +function getTypes(registry: ISavedObjectTypeRegistry, type?: string | string[]) { if (!type) { - return Object.keys(getRootPropertiesObjects(mappings)); + return registry.getAllTypes().map((registeredType) => registeredType.name); } - - if (Array.isArray(type)) { - return type; - } - - return [type]; + return Array.isArray(type) ? type : [type]; } /** * Get the field params based on the types, searchFields, and rootSearchFields */ -function getFieldsForTypes( +function getSimpleQueryStringTypeFields( types: string[], searchFields: string[] = [], rootSearchFields: string[] = [] @@ -130,7 +124,6 @@ export interface HasReferenceQueryParams { export type SearchOperator = 'AND' | 'OR'; interface QueryParams { - mappings: IndexMapping; registry: ISavedObjectTypeRegistry; namespaces?: string[]; type?: string | string[]; @@ -188,11 +181,26 @@ export function getClauseForReference(reference: HasReferenceQueryParams) { }; } +// A de-duplicated set of namespaces makes for a more efficient query. +// +// Additionally, we treat the `*` namespace as the `default` namespace. +// In the Default Distribution, the `*` is automatically expanded to include all available namespaces. +// However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` +// to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, +// since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place +// would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. +// We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 +const normalizeNamespaces = (namespacesToNormalize?: string[]) => + namespacesToNormalize + ? Array.from( + new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) + ) + : undefined; + /** * Get the "query" related keys for the search body */ export function getQueryParams({ - mappings, registry, namespaces, type, @@ -206,7 +214,7 @@ export function getQueryParams({ kueryNode, }: QueryParams) { const types = getTypes( - mappings, + registry, typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type ); @@ -214,28 +222,10 @@ export function getQueryParams({ hasReference = [hasReference]; } - // A de-duplicated set of namespaces makes for a more effecient query. - // - // Additonally, we treat the `*` namespace as the `default` namespace. - // In the Default Distribution, the `*` is automatically expanded to include all available namespaces. - // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` - // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, - // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place - // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. - // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 - const normalizeNamespaces = (namespacesToNormalize?: string[]) => - namespacesToNormalize - ? Array.from( - new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) - ) - : undefined; - const bool: any = { filter: [ ...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []), - ...(hasReference && hasReference.length - ? [getReferencesFilter(hasReference, hasReferenceOperator)] - : []), + ...(hasReference?.length ? [getReferencesFilter(hasReference, hasReferenceOperator)] : []), { bool: { should: types.map((shouldType) => { @@ -251,16 +241,133 @@ export function getQueryParams({ }; if (search) { - bool.must = [ - { - simple_query_string: { - query: search, - ...getFieldsForTypes(types, searchFields, rootSearchFields), - ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), - }, - }, - ]; + const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); + const simpleQueryStringClause = getSimpleQueryStringClause({ + search, + types, + searchFields, + rootSearchFields, + defaultSearchOperator, + }); + + if (useMatchPhrasePrefix) { + bool.should = [ + simpleQueryStringClause, + ...getMatchPhrasePrefixClauses({ search, searchFields, types, registry }), + ]; + bool.minimum_should_match = 1; + } else { + bool.must = [simpleQueryStringClause]; + } } return { query: { bool } }; } + +// we only want to add match_phrase_prefix clauses +// if the search is a prefix search +const shouldUseMatchPhrasePrefix = (search: string): boolean => { + return search.trim().endsWith('*'); +}; + +const getMatchPhrasePrefixClauses = ({ + search, + searchFields, + registry, + types, +}: { + search: string; + searchFields?: string[]; + types: string[]; + registry: ISavedObjectTypeRegistry; +}) => { + // need to remove the prefix search operator + const query = search.replace(/[*]$/, ''); + const mppFields = getMatchPhrasePrefixFields({ searchFields, types, registry }); + return mppFields.map(({ field, boost }) => { + return { + match_phrase_prefix: { + [field]: { + query, + boost, + }, + }, + }; + }); +}; + +interface FieldWithBoost { + field: string; + boost?: number; +} + +const getMatchPhrasePrefixFields = ({ + searchFields = [], + types, + registry, +}: { + searchFields?: string[]; + types: string[]; + registry: ISavedObjectTypeRegistry; +}): FieldWithBoost[] => { + const output: FieldWithBoost[] = []; + + searchFields = searchFields.filter((field) => field !== '*'); + let fields: string[]; + if (searchFields.length === 0) { + fields = types.reduce((typeFields, type) => { + const defaultSearchField = registry.getType(type)?.management?.defaultSearchField; + if (defaultSearchField) { + return [...typeFields, `${type}.${defaultSearchField}`]; + } + return typeFields; + }, [] as string[]); + } else { + fields = []; + for (const field of searchFields) { + fields = fields.concat(types.map((type) => `${type}.${field}`)); + } + } + + fields.forEach((rawField) => { + const [field, rawBoost] = rawField.split('^'); + let boost: number = 1; + if (rawBoost) { + try { + boost = parseInt(rawBoost, 10); + } catch (e) { + boost = 1; + } + } + if (isNaN(boost)) { + boost = 1; + } + output.push({ + field, + boost, + }); + }); + return output; +}; + +const getSimpleQueryStringClause = ({ + search, + types, + searchFields, + rootSearchFields, + defaultSearchOperator, +}: { + search: string; + types: string[]; + searchFields?: string[]; + rootSearchFields?: string[]; + defaultSearchOperator?: SearchOperator; +}) => { + return { + simple_query_string: { + query: search, + ...getSimpleQueryStringTypeFields(types, searchFields, rootSearchFields), + ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), + }, + }; +}; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index a9f26f71a3f2b..3522ab9ef1736 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -76,7 +76,6 @@ describe('getSearchDsl', () => { getSearchDsl(mappings, registry, opts); expect(getQueryParams).toHaveBeenCalledTimes(1); expect(getQueryParams).toHaveBeenCalledWith({ - mappings, registry, namespaces: opts.namespaces, type: opts.type, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index d5da82e5617be..bddecc4d7f649 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -71,7 +71,6 @@ export function getSearchDsl( return { ...getQueryParams({ - mappings, registry, namespaces, type, diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index c2e36b4a669ff..e5da46644672b 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -334,6 +334,70 @@ export default function ({ getService }) { }); }); + describe('searching for special characters', () => { + before(() => esArchiver.load('saved_objects/find_edgecases')); + after(() => esArchiver.unload('saved_objects/find_edgecases')); + + it('can search for objects with dashes', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'my-vis*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['my-visualization']); + })); + + it('can search with the prefix search character just after a special one', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'my-*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['my-visualization']); + })); + + it('can search for objects with asterisk', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'some*vi*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['some*visualization']); + })); + + it('can still search tokens by prefix', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'visuali*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql([ + 'my-visualization', + 'some*visualization', + ]); + })); + }); + describe('without kibana index', () => { before( async () => diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json new file mode 100644 index 0000000000000..0c8b35fd3f499 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json @@ -0,0 +1,93 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:title-with-dash", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "my-visualization", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:title-with-asterisk", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "some*visualization", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:noise-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Just some noise in the dataset", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:noise-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Just some noise in the dataset", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json new file mode 100644 index 0000000000000..e601c43431437 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json @@ -0,0 +1,267 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "namespace": { + "type": "keyword" + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} From 202dec7c24d14ae2e20be97743089e377daf3047 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 9 Nov 2020 14:17:29 +0300 Subject: [PATCH 3/9] Enable send to background in TSVB (#82835) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/vis_type_timeseries/common/types.ts | 3 ++- .../vis_type_timeseries/common/vis_schema.ts | 1 + .../vis_type_timeseries/public/request_handler.js | 4 +++- .../strategies/abstract_search_strategy.test.js | 8 +++++++- .../strategies/abstract_search_strategy.ts | 13 +++++++++---- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 4520069244527..8973060848b41 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -18,8 +18,9 @@ */ import { TypeOf } from '@kbn/config-schema'; -import { metricsItems, panel, seriesItems } from './vis_schema'; +import { metricsItems, panel, seriesItems, visPayloadSchema } from './vis_schema'; export type SeriesItemsSchema = TypeOf; export type MetricsItemsSchema = TypeOf; export type PanelSchema = TypeOf; +export type VisPayload = TypeOf; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 40f776050617e..27f09fb574b0f 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -273,4 +273,5 @@ export const visPayloadSchema = schema.object({ min: stringRequired, max: stringRequired, }), + sessionId: schema.maybe(schema.string()), }); diff --git a/src/plugins/vis_type_timeseries/public/request_handler.js b/src/plugins/vis_type_timeseries/public/request_handler.js index e33d0e254f609..12b7f3d417ef6 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.js +++ b/src/plugins/vis_type_timeseries/public/request_handler.js @@ -32,7 +32,8 @@ export const metricsRequestHandler = async ({ const config = getUISettings(); const timezone = getTimezone(config); const uiStateObj = uiState.get(visParams.type, {}); - const parsedTimeRange = getDataStart().query.timefilter.timefilter.calculateBounds(timeRange); + const dataSearch = getDataStart(); + const parsedTimeRange = dataSearch.query.timefilter.timefilter.calculateBounds(timeRange); const scaledDataFormat = config.get('dateFormat:scaled'); const dateFormat = config.get('dateFormat'); @@ -53,6 +54,7 @@ export const metricsRequestHandler = async ({ panels: [visParams], state: uiStateObj, savedObjectId: savedObjectId || 'unsaved', + sessionId: dataSearch.search.session.getSessionId(), }), }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index 9710f7daf69b6..2c38e883cd69f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -28,6 +28,7 @@ describe('AbstractSearchStrategy', () => { beforeEach(() => { mockedFields = {}; req = { + payload: {}, pre: { indexPatternsService: { getFieldsForWildcard: jest.fn().mockReturnValue(mockedFields), @@ -60,6 +61,9 @@ describe('AbstractSearchStrategy', () => { const responses = await abstractSearchStrategy.search( { + payload: { + sessionId: 1, + }, requestContext: { search: { search: searchFn }, }, @@ -76,7 +80,9 @@ describe('AbstractSearchStrategy', () => { }, indexType: undefined, }, - {} + { + sessionId: 1, + } ); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index eb22fcb1dd689..b1e21edf8b588 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -23,8 +23,10 @@ import { IUiSettingsClient, SavedObjectsClientContract, } from 'kibana/server'; + import { Framework } from '../../../plugin'; import { IndexPatternsFetcher } from '../../../../../data/server'; +import { VisPayload } from '../../../../common/types'; /** * ReqFacade is a regular KibanaRequest object extended with additional service @@ -32,17 +34,17 @@ import { IndexPatternsFetcher } from '../../../../../data/server'; * * This will be replaced by standard KibanaRequest and RequestContext objects in a later version. */ -export type ReqFacade = FakeRequest & { +export interface ReqFacade extends FakeRequest { requestContext: RequestHandlerContext; framework: Framework; - payload: unknown; + payload: T; pre: { indexPatternsService?: IndexPatternsFetcher; }; getUiSettingsService: () => IUiSettingsClient; getSavedObjectsClient: () => SavedObjectsClientContract; getEsShardTimeout: () => Promise; -}; +} export class AbstractSearchStrategy { public indexType?: string; @@ -53,8 +55,10 @@ export class AbstractSearchStrategy { this.additionalParams = additionalParams; } - async search(req: ReqFacade, bodies: any[], options = {}) { + async search(req: ReqFacade, bodies: any[], options = {}) { const requests: any[] = []; + const { sessionId } = req.payload; + bodies.forEach((body) => { requests.push( req.requestContext @@ -67,6 +71,7 @@ export class AbstractSearchStrategy { indexType: this.indexType, }, { + sessionId, ...options, } ) From 6110ef82a3a0537a82738c3f039d850772f24306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Mon, 9 Nov 2020 12:43:11 +0100 Subject: [PATCH 4/9] [Logs UI] Fix errors during navigation (#78319) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../containers/logs/log_entries/index.ts | 24 ++++++++---- .../infra/public/utils/use_tracked_promise.ts | 37 ++++++++++++++++--- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 4c8c610794b2e..214bb16b24283 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { useEffect, useState, useReducer, useCallback } from 'react'; +import { useMountedState } from 'react-use'; import createContainer from 'constate'; import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; @@ -146,15 +147,20 @@ const useFetchEntriesEffect = ( props: LogEntriesProps ) => { const { services } = useKibanaContextForPlugin(); + const isMounted = useMountedState(); const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); + const dispatchIfMounted = useCallback((action) => (isMounted() ? dispatch(action) : undefined), [ + dispatch, + isMounted, + ]); const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { if (!props.startTimestamp || !props.endTimestamp) { return; } - dispatch({ type: Action.FetchingNewEntries }); + dispatchIfMounted({ type: Action.FetchingNewEntries }); try { const commonFetchArgs: LogEntriesBaseRequest = { @@ -175,13 +181,15 @@ const useFetchEntriesEffect = ( }; const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - dispatch({ type: Action.ReceiveNewEntries, payload }); + dispatchIfMounted({ type: Action.ReceiveNewEntries, payload }); // Move position to the bottom if it's the first load. // Do it in the next tick to allow the `dispatch` to fire if (!props.timeKey && payload.bottomCursor) { setTimeout(() => { - props.jumpToTargetPosition(payload.bottomCursor!); + if (isMounted()) { + props.jumpToTargetPosition(payload.bottomCursor!); + } }); } else if ( props.timeKey && @@ -192,7 +200,7 @@ const useFetchEntriesEffect = ( props.jumpToTargetPosition(payload.topCursor); } } catch (e) { - dispatch({ type: Action.ErrorOnNewEntries }); + dispatchIfMounted({ type: Action.ErrorOnNewEntries }); } }; @@ -210,7 +218,7 @@ const useFetchEntriesEffect = ( return; } - dispatch({ type: Action.FetchingMoreEntries }); + dispatchIfMounted({ type: Action.FetchingMoreEntries }); try { const commonFetchArgs: LogEntriesBaseRequest = { @@ -232,14 +240,14 @@ const useFetchEntriesEffect = ( const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - dispatch({ + dispatchIfMounted({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); return payload.bottomCursor; } catch (e) { - dispatch({ type: Action.ErrorOnMoreEntries }); + dispatchIfMounted({ type: Action.ErrorOnMoreEntries }); } }; @@ -322,7 +330,7 @@ const useFetchEntriesEffect = ( after: props.endTimestamp > prevParams.endTimestamp, }; - dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + dispatchIfMounted({ type: Action.ExpandRange, payload: shouldExpand }); }; const expandRangeEffectDependencies = [ diff --git a/x-pack/plugins/infra/public/utils/use_tracked_promise.ts b/x-pack/plugins/infra/public/utils/use_tracked_promise.ts index 9951b62fa64a3..42518127f68bf 100644 --- a/x-pack/plugins/infra/public/utils/use_tracked_promise.ts +++ b/x-pack/plugins/infra/public/utils/use_tracked_promise.ts @@ -6,13 +6,15 @@ /* eslint-disable max-classes-per-file */ -import { DependencyList, useEffect, useMemo, useRef, useState } from 'react'; +import { DependencyList, useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useMountedState } from 'react-use'; interface UseTrackedPromiseArgs { createPromise: (...args: Arguments) => Promise; onResolve?: (result: Result) => void; onReject?: (value: unknown) => void; cancelPreviousOn?: 'creation' | 'settlement' | 'resolution' | 'rejection' | 'never'; + triggerOrThrow?: 'always' | 'whenMounted'; } /** @@ -64,6 +66,16 @@ interface UseTrackedPromiseArgs { * The last argument is a normal React hook dependency list that indicates * under which conditions a new reference to the configuration object should be * used. + * + * The `onResolve`, `onReject` and possible uncatched errors are only triggered + * if the underlying component is mounted. To ensure they always trigger (i.e. + * if the promise is called in a `useLayoutEffect`) use the `triggerOrThrow` + * attribute: + * + * 'whenMounted': (default) they are called only if the component is mounted. + * + * 'always': they always call. The consumer is then responsible of ensuring no + * side effects happen if the underlying component is not mounted. */ export const useTrackedPromise = ( { @@ -71,9 +83,20 @@ export const useTrackedPromise = ( onResolve = noOp, onReject = noOp, cancelPreviousOn = 'never', + triggerOrThrow = 'whenMounted', }: UseTrackedPromiseArgs, dependencies: DependencyList ) => { + const isComponentMounted = useMountedState(); + const shouldTriggerOrThrow = useCallback(() => { + switch (triggerOrThrow) { + case 'always': + return true; + case 'whenMounted': + return isComponentMounted(); + } + }, [isComponentMounted, triggerOrThrow]); + /** * If a promise is currently pending, this holds a reference to it and its * cancellation function. @@ -144,7 +167,7 @@ export const useTrackedPromise = ( (pendingPromise) => pendingPromise.promise !== newPendingPromise.promise ); - if (onResolve) { + if (onResolve && shouldTriggerOrThrow()) { onResolve(value); } @@ -173,11 +196,13 @@ export const useTrackedPromise = ( (pendingPromise) => pendingPromise.promise !== newPendingPromise.promise ); - if (onReject) { - onReject(value); - } + if (shouldTriggerOrThrow()) { + if (onReject) { + onReject(value); + } - throw value; + throw value; + } } ), }; From 858befef44d10db1ad388b10e48cad84991c8355 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 9 Nov 2020 13:46:46 +0100 Subject: [PATCH 5/9] [APM] Expose APM event client as part of plugin contract (#82724) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../create_apm_event_client/index.ts | 21 +++++---- .../apm/server/lib/helpers/setup_request.ts | 3 +- x-pack/plugins/apm/server/plugin.ts | 47 +++++++++++++++++-- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index 2bfd3c94ed34c..9020cb1b9953a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -7,14 +7,16 @@ import { ValuesType } from 'utility-types'; import { APMBaseDoc } from '../../../../../typings/es_schemas/raw/apm_base_doc'; import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { + KibanaRequest, + LegacyScopedClusterClient, +} from '../../../../../../../../src/core/server'; import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../typings/elasticsearch'; import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; import { callClientWithDebug } from '../call_client_with_debug'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; @@ -51,20 +53,23 @@ type TypedSearchResponse< export type APMEventClient = ReturnType; export function createApmEventClient({ - context, + esClient, + debug, request, indices, options: { includeFrozen } = { includeFrozen: false }, }: { - context: APMRequestHandlerContext; + esClient: Pick< + LegacyScopedClusterClient, + 'callAsInternalUser' | 'callAsCurrentUser' + >; + debug: boolean; request: KibanaRequest; indices: ApmIndicesConfig; options: { includeFrozen: boolean; }; }) { - const client = context.core.elasticsearch.legacy.client; - return { search( params: TParams, @@ -77,14 +82,14 @@ export function createApmEventClient({ : withProcessorEventFilter; return callClientWithDebug({ - apiCaller: client.callAsCurrentUser, + apiCaller: esClient.callAsCurrentUser, operationName: 'search', params: { ...withPossibleLegacyDataFilter, ignore_throttled: !includeFrozen, }, request, - debug: context.params.query._debug, + debug, }); }, }; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5e75535c678b3..363c4128137e0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -88,7 +88,8 @@ export async function setupRequest( const coreSetupRequest = { indices, apmEventClient: createApmEventClient({ - context, + esClient: context.core.elasticsearch.legacy.client, + debug: context.params.query._debug, request, indices, options: { includeFrozen }, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index d3341b6c1b163..44269b1775953 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -10,14 +10,17 @@ import { map, take } from 'rxjs/operators'; import { CoreSetup, CoreStart, + KibanaRequest, Logger, Plugin, PluginInitializerContext, + RequestHandlerContext, } from 'src/core/server'; import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { UI_SETTINGS } from '../../../../src/plugins/data/common'; import { ActionsPlugin } from '../../actions/server'; import { AlertingPlugin } from '../../alerts/server'; import { CloudSetup } from '../../cloud/server'; @@ -30,6 +33,7 @@ import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; @@ -42,6 +46,11 @@ import { uiSettings } from './ui_settings'; export interface APMPluginSetup { config$: Observable; getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => Promise>; } export class APMPlugin implements Plugin { @@ -141,13 +150,41 @@ export class APMPlugin implements Plugin { }, }); + const boundGetApmIndices = async () => + getApmIndices({ + savedObjectsClient: await getInternalSavedObjectsClient(core), + config: await mergedConfig$.pipe(take(1)).toPromise(), + }); + return { config$: mergedConfig$, - getApmIndices: async () => - getApmIndices({ - savedObjectsClient: await getInternalSavedObjectsClient(core), - config: await mergedConfig$.pipe(take(1)).toPromise(), - }), + getApmIndices: boundGetApmIndices, + createApmEventClient: async ({ + request, + context, + debug, + }: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => { + const [indices, includeFrozen] = await Promise.all([ + boundGetApmIndices(), + context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN), + ]); + + const esClient = context.core.elasticsearch.legacy.client; + + return createApmEventClient({ + debug: debug ?? false, + esClient, + request, + indices, + options: { + includeFrozen, + }, + }); + }, }; } From 3c525d7341ebe683e6ed8827927c5b0c18e97631 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 9 Nov 2020 12:56:56 +0000 Subject: [PATCH 6/9] [Alerting] adds an Run When field in the alert flyout to assign the action to an Action Group (#82472) Adds a `RunsWhen` field to actions in the Alerts Flyout when creating / editing an Alert which allows the user to assign specific actions to a certain Action Groups --- .../public/alert_types/astros.tsx | 6 +- .../server/alert_types/always_firing.ts | 18 +- x-pack/plugins/triggers_actions_ui/README.md | 21 +- .../lib/check_action_type_enabled.scss | 12 +- .../action_form.test.tsx | 89 ++- .../action_connector_form/action_form.tsx | 637 +++++------------- .../action_type_form.tsx | 339 ++++++++++ .../connector_add_inline.tsx | 153 +++++ .../connector_add_modal.test.tsx | 3 +- .../connector_add_modal.tsx | 13 +- .../sections/alert_form/alert_form.tsx | 51 +- .../alert_create_flyout.ts | 57 ++ .../fixtures/plugins/alerts/server/plugin.ts | 1 + 13 files changed, 846 insertions(+), 554 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx diff --git a/x-pack/examples/alerting_example/public/alert_types/astros.tsx b/x-pack/examples/alerting_example/public/alert_types/astros.tsx index 73c7dfea1263b..54f989b93e22f 100644 --- a/x-pack/examples/alerting_example/public/alert_types/astros.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/astros.tsx @@ -127,9 +127,9 @@ export const PeopleinSpaceExpression: React.FunctionComponent - errs.map((e) => ( -

+ Object.entries(errors).map(([field, errs]: [string, string[]], fieldIndex) => + errs.map((e, index) => ( +

{field}:`: ${errs}`

)) diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index bb1cb0d97689b..d02406a23045e 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -5,25 +5,31 @@ */ import uuid from 'uuid'; -import { range } from 'lodash'; +import { range, random } from 'lodash'; import { AlertType } from '../../../../plugins/alerts/server'; import { DEFAULT_INSTANCES_TO_GENERATE, ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; +const ACTION_GROUPS = [ + { id: 'small', name: 'small' }, + { id: 'medium', name: 'medium' }, + { id: 'large', name: 'large' }, +]; + export const alertType: AlertType = { id: 'example.always-firing', name: 'Always firing', - actionGroups: [{ id: 'default', name: 'default' }], - defaultActionGroupId: 'default', + actionGroups: ACTION_GROUPS, + defaultActionGroupId: 'small', async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) { const count = (state.count ?? 0) + 1; range(instances) - .map(() => ({ id: uuid.v4() })) - .forEach((instance: { id: string }) => { + .map(() => ({ id: uuid.v4(), tshirtSize: ACTION_GROUPS[random(0, 2)].id! })) + .forEach((instance: { id: string; tshirtSize: string }) => { services .alertInstanceFactory(instance.id) .replaceState({ triggerdOnCycle: count }) - .scheduleActions('default'); + .scheduleActions(instance.tshirtSize); }); return { diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index aabb9899cb343..32e157255c0cc 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1319,19 +1319,19 @@ ActionForm Props definition: interface ActionAccordionFormProps { actions: AlertAction[]; defaultActionGroupId: string; + actionGroups?: ActionGroup[]; setActionIdByIndex: (id: string, index: number) => void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; - actionTypeRegistry: TypeRegistry; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; + actionTypeRegistry: ActionTypeRegistryContract; + toastNotifications: ToastsSetup; + docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: ActionVariable[]; defaultActionMessage?: string; - consumer: string; + capabilities: ApplicationStart['capabilities']; } ``` @@ -1339,17 +1339,20 @@ interface ActionAccordionFormProps { |Property|Description| |---|---| |actions|List of actions comes from alert.actions property.| -|defaultActionGroupId|Default action group id to which each new action will belong to.| +|defaultActionGroupId|Default action group id to which each new action will belong by default.| +|actionGroups|Optional. List of action groups to which new action can be assigned. The RunWhen field is only displayed when these action groups are specified| |setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setActionGroupIdByIndex|Function for changing action 'group' by the proper index in alert.actions array.| |setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| |setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| |http|HttpSetup needed for executing API calls.| |actionTypeRegistry|Registry for action types.| -|toastNotifications|Toast messages.| +|toastNotifications|Toast messages Plugin Setup Contract.| +|docLinks|Documentation links Plugin Start Contract.| |actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| |actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| |defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| -|consumer|Name of the plugin that creates an action.| +|capabilities|Kibana core's Capabilities ApplicationStart['capabilities'].| AlertsContextProvider value options: diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index 24dbb865742d8..bb622829e997a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -3,9 +3,15 @@ } .actAccordionActionForm { - .euiCard { - box-shadow: none; - } + background-color: $euiColorLightestShade; +} + +.actAccordionActionForm .euiCard { + box-shadow: none; +} + +.actAccordionActionForm__button { + padding: $euiSizeM; } .actConnectorsListGrid { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 7c718e8248e41..94452e70e6bfa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -6,7 +6,6 @@ import React, { Fragment, lazy } from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; @@ -112,8 +111,6 @@ describe('action_form', () => { }; describe('action_form in alert', () => { - let wrapper: ReactWrapper; - async function setup(customActions?: AlertAction[]) { const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce([ @@ -217,7 +214,7 @@ describe('action_form', () => { mutedInstanceIds: [], } as unknown) as Alert; - wrapper = mountWithIntl( + const wrapper = mountWithIntl( { setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} + actionGroups={[{ id: 'default', name: 'Default' }]} + setActionGroupIdByIndex={(group: string, index: number) => { + initialAlert.actions[index].group = group; + }} setAlertProperty={(_updatedActions: AlertAction[]) => {}} setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) @@ -297,10 +298,12 @@ describe('action_form', () => { await nextTick(); wrapper.update(); }); + + return wrapper; } it('renders available action cards', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); @@ -314,7 +317,7 @@ describe('action_form', () => { }); it('does not render action types disabled by config', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-config-ActionTypeSelectOption"]' ); @@ -322,52 +325,72 @@ describe('action_form', () => { }); it('render action types which is preconfigured only (disabled by config and with preconfigured connectors)', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); expect(actionOption.exists()).toBeTruthy(); }); + it('renders available action groups for the selected action type', async () => { + const wrapper = await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-0"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-0-option-default", + "inputDisplay": "Default", + "value": "default", + }, + ] + `); + }); + it('renders available connectors for the selected action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); actionOption.first().simulate('click'); const combobox = wrapper.find(`[data-test-subj="selectActionConnector-${actionType.id}"]`); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test", - "key": "test", - "label": "Test connector ", - }, - Object { - "id": "test2", - "key": "test2", - "label": "Test connector 2 (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test", + "key": "test", + "label": "Test connector ", + }, + Object { + "id": "test2", + "key": "test2", + "label": "Test connector 2 (preconfigured)", + }, + ] + `); }); it('renders only preconfigured connectors for the selected preconfigured action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const combobox = wrapper.find('[data-test-subj="selectActionConnector-preconfigured"]'); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test3", - "key": "test3", - "label": "Preconfigured Only (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test3", + "key": "test3", + "label": "Preconfigured Only (preconfigured)", + }, + ] + `); }); it('does not render "Add connector" button for preconfigured only action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const preconfigPannel = wrapper.find('[data-test-subj="alertActionAccordion-default"]'); @@ -378,7 +401,7 @@ describe('action_form', () => { }); it('renders action types disabled by license', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-license-ActionTypeSelectOption"]' ); @@ -391,7 +414,7 @@ describe('action_form', () => { }); it(`shouldn't render action types without params component`, async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionTypeWithoutParams.id}-ActionTypeSelectOption"]` ); 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 74432157f5659..3a7341afe3e07 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, Suspense, useState, useEffect } from 'react'; +import React, { Fragment, useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -14,25 +14,13 @@ import { EuiIcon, EuiTitle, EuiSpacer, - EuiFormRow, - EuiComboBox, EuiKeyPadMenuItem, - EuiAccordion, - EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiToolTip, - EuiIconTip, EuiLink, - EuiCallOut, - EuiHorizontalRule, - EuiText, - EuiLoadingSpinner, } from '@elastic/eui'; import { HttpSetup, ToastsSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { - IErrorObject, ActionTypeModel, ActionTypeRegistryContract, AlertAction, @@ -43,15 +31,19 @@ import { } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; +import { ActionTypeForm, ActionTypeFormProps } 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 { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; -import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionGroup } from '../../../../../alerts/common'; -interface ActionAccordionFormProps { +export interface ActionAccordionFormProps { actions: AlertAction[]; defaultActionGroupId: string; + actionGroups?: ActionGroup[]; setActionIdByIndex: (id: string, index: number) => void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; @@ -74,7 +66,9 @@ interface ActiveActionConnectorState { export const ActionForm = ({ actions, defaultActionGroupId, + actionGroups, setActionIdByIndex, + setActionGroupIdByIndex, setAlertProperty, setActionParamsProperty, http, @@ -88,8 +82,6 @@ export const ActionForm = ({ capabilities, docLinks, }: ActionAccordionFormProps) => { - const canSave = hasSaveActionsCapability(capabilities); - const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -101,6 +93,10 @@ export const ActionForm = ({ const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [emptyActionsIds, setEmptyActionsIds] = useState([]); + const closeAddConnectorModal = useCallback(() => setAddModalVisibility(false), [ + setAddModalVisibility, + ]); + // load action types useEffect(() => { (async () => { @@ -183,359 +179,6 @@ export const ActionForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actions, connectors]); - const preconfiguredMessage = i18n.translate( - 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', - { - defaultMessage: '(preconfigured)', - } - ); - - const getSelectedOptions = (actionItemId: string) => { - const selectedConnector = connectors.find((connector) => connector.id === actionItemId); - if ( - !selectedConnector || - // if selected connector is not preconfigured and action type is for preconfiguration only, - // do not show regular connectors of this type - (actionTypesIndex && - !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && - !selectedConnector.isPreconfigured) - ) { - return []; - } - const optionTitle = `${selectedConnector.name} ${ - selectedConnector.isPreconfigured ? preconfiguredMessage : '' - }`; - return [ - { - label: optionTitle, - value: optionTitle, - id: actionItemId, - 'data-test-subj': 'itemActionConnector', - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - actionParamsErrors: { - errors: IErrorObject; - }, - index: number - ) => { - if (!actionTypesIndex) { - return null; - } - - const actionType = actionTypesIndex[actionItem.actionTypeId]; - - const optionsList = connectors - .filter( - (connectorItem) => - connectorItem.actionTypeId === actionItem.actionTypeId && - // include only enabled by config connectors or preconfigured - (actionType.enabledInConfig || connectorItem.isPreconfigured) - ) - .map(({ name, id, isPreconfigured }) => ({ - label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, - key: id, - id, - })); - const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; - const checkEnabledResult = checkActionFormActionTypeEnabled( - actionTypesIndex[actionConnector.actionTypeId], - connectors.filter((connector) => connector.isPreconfigured) - ); - - const accordionContent = checkEnabledResult.isEnabled ? ( - - - - - } - labelAppend={ - canSave && - actionTypesIndex && - actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - - ) : null - } - > - { - setActionIdByIndex(selectedOptions[0].id ?? '', index); - }} - isClearable={false} - /> - - - - - {ParamsFieldsComponent ? ( - - - - - - } - > - - - ) : null} - - ) : ( - checkEnabledResult.messageCard - ); - - return ( - - - - - - - -
- - - - - - {checkEnabledResult.isEnabled === false && ( - - - - )} - - -
-
-
- - } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {accordionContent} -
- -
- ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - - const noConnectorsLabel = ( - - ); - return ( - - - - - - - -
- -
-
-
- - } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {canSave ? ( - actionItem.id === emptyId) ? ( - noConnectorsLabel - ) : ( - - ) - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - , - ]} - /> - ) : ( - -

- -

-
- )} -
- -
- ); - }; - function addActionType(actionTypeModel: ActionTypeModel) { if (!defaultActionGroupId) { toastNotifications!.addDanger({ @@ -628,116 +271,172 @@ export const ActionForm = ({ }); } - const alertActionsList = actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find((field) => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - - const actionErrors: { errors: IErrorObject } = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - - return getActionTypeForm(actionItem, actionConnector, actionErrors, index); - }); - - return ( + return isLoadingConnectors ? ( + + + + ) : ( - {isLoadingConnectors ? ( - + +

- - ) : ( - - -

- + + + {actionTypesIndex && + actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find((field) => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return ( + { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) + .length === 0 + ); + setActiveActionItem(undefined); + }} + onAddConnector={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} /> -

-
- - {alertActionsList} - {isAddActionPanelOpen === false ? ( -
- - - - setIsAddActionPanelOpen(true)} - > - - - - -
- ) : null} - {isAddActionPanelOpen ? ( - - - - -
+ ); + } + + const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + + return ( + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> + ); + })} + + {isAddActionPanelOpen ? ( + + + + +
+ +
+
+
+ {hasDisabledByLicenseActionTypes && ( + + +
+ -
-
-
- {hasDisabledByLicenseActionTypes && ( - - -
- - - -
-
-
- )} -
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
- ) : null} + +
+
+
+ )} +
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} +
+ ) : ( + + + setIsAddActionPanelOpen(true)} + > + + + + )} - {actionTypesIndex && activeActionItem ? ( + {actionTypesIndex && activeActionItem && addModalVisible ? ( { connectors.push(savedAction); setActionIdByIndex(savedAction.id, activeActionItem.index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx new file mode 100644 index 0000000000000..38468283b9c19 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -0,0 +1,339 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Suspense, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiAccordion, + EuiButtonIcon, + EuiButtonEmpty, + EuiIconTip, + EuiText, + EuiFormLabel, + EuiFormControlLayout, + EuiSuperSelect, + EuiLoadingSpinner, + EuiBadge, +} from '@elastic/eui'; +import { IErrorObject, AlertAction, ActionTypeIndex, ActionConnector } from '../../../types'; +import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +export type ActionTypeFormProps = { + actionItem: AlertAction; + actionConnector: ActionConnector; + actionParamsErrors: { + errors: IErrorObject; + }; + index: number; + onAddConnector: () => void; + onConnectorSelected: (id: string) => void; + onDeleteAction: () => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + actionTypesIndex: ActionTypeIndex; + connectors: ActionConnector[]; +} & Pick< + ActionAccordionFormProps, + | 'defaultActionGroupId' + | 'actionGroups' + | 'setActionGroupIdByIndex' + | 'setActionParamsProperty' + | 'http' + | 'actionTypeRegistry' + | 'toastNotifications' + | 'docLinks' + | 'messageVariables' + | 'defaultActionMessage' + | 'capabilities' +>; + +const preconfiguredMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', + { + defaultMessage: '(preconfigured)', + } +); + +export const ActionTypeForm = ({ + actionItem, + actionConnector, + actionParamsErrors, + index, + onAddConnector, + onConnectorSelected, + onDeleteAction, + setActionParamsProperty, + actionTypesIndex, + connectors, + http, + toastNotifications, + docLinks, + capabilities, + actionTypeRegistry, + defaultActionGroupId, + defaultActionMessage, + messageVariables, + actionGroups, + setActionGroupIdByIndex, +}: ActionTypeFormProps) => { + const [isOpen, setIsOpen] = useState(true); + + const canSave = hasSaveActionsCapability(capabilities); + const getSelectedOptions = (actionItemId: string) => { + const selectedConnector = connectors.find((connector) => connector.id === actionItemId); + if ( + !selectedConnector || + // if selected connector is not preconfigured and action type is for preconfiguration only, + // do not show regular connectors of this type + (actionTypesIndex && + !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && + !selectedConnector.isPreconfigured) + ) { + return []; + } + const optionTitle = `${selectedConnector.name} ${ + selectedConnector.isPreconfigured ? preconfiguredMessage : '' + }`; + return [ + { + label: optionTitle, + value: optionTitle, + id: actionItemId, + 'data-test-subj': 'itemActionConnector', + }, + ]; + }; + + const actionType = actionTypesIndex[actionItem.actionTypeId]; + + const optionsList = connectors + .filter( + (connectorItem) => + connectorItem.actionTypeId === actionItem.actionTypeId && + // include only enabled by config connectors or preconfigured + (actionType.enabledInConfig || connectorItem.isPreconfigured) + ) + .map(({ name, id, isPreconfigured }) => ({ + label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionTypesIndex[actionConnector.actionTypeId], + connectors.filter((connector) => connector.isPreconfigured) + ); + + const defaultActionGroup = actionGroups?.find(({ id }) => id === defaultActionGroupId); + const selectedActionGroup = + actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; + + const accordionContent = checkEnabledResult.isEnabled ? ( + + {actionGroups && selectedActionGroup && setActionGroupIdByIndex && ( + + + + + + + } + > + ({ + value, + inputDisplay: name, + 'data-test-subj': `addNewActionConnectorActionGroup-${index}-option-${value}`, + }))} + valueOfSelected={selectedActionGroup.id} + onChange={(group) => { + setActionGroupIdByIndex(group, index); + }} + /> + + + + + + )} + + + + } + labelAppend={ + canSave && + actionTypesIndex && + actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( + + + + ) : null + } + > + { + onConnectorSelected(selectedOptions[0].id ?? ''); + }} + isClearable={false} + /> + + + + + {ParamsFieldsComponent ? ( + + + + + + } + > + + + ) : null} + + ) : ( + checkEnabledResult.messageCard + ); + + return ( + + + + + + + +
+ + + + + {selectedActionGroup && !isOpen && ( + + {selectedActionGroup.name} + + )} + + {checkEnabledResult.isEnabled === false && ( + + + + )} + + +
+
+
+ + } + extraAction={ + + } + > + {accordionContent} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx new file mode 100644 index 0000000000000..97baf4a36cb4c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiCallOut, + EuiText, +} from '@elastic/eui'; +import { AlertAction, ActionTypeIndex } from '../../../types'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +type AddConnectorInFormProps = { + actionTypesIndex: ActionTypeIndex; + actionItem: AlertAction; + index: number; + onAddConnector: () => void; + onDeleteConnector: () => void; + emptyActionsIds: string[]; +} & Pick; + +export const AddConnectorInline = ({ + actionTypesIndex, + actionItem, + index, + onAddConnector, + onDeleteConnector, + actionTypeRegistry, + emptyActionsIds, + defaultActionGroupId, + capabilities, +}: AddConnectorInFormProps) => { + const canSave = hasSaveActionsCapability(capabilities); + + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + + const noConnectorsLabel = ( + + ); + return ( + + + + + + + +
+ +
+
+
+ + } + extraAction={ + + } + paddingSize="l" + > + {canSave ? ( + actionItem.id === emptyId) ? ( + noConnectorsLabel + ) : ( + + ) + } + actions={[ + + + , + ]} + /> + ) : ( + +

+ +

+
+ )} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index cba9eea3cf3f7..71a3936ed5055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -65,8 +65,7 @@ describe('connector_add_modal', () => { const wrapper = mountWithIntl( {}} + onClose={() => {}} actionType={actionType} http={deps!.http} actionTypeRegistry={deps!.actionTypeRegistry} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 13ec8395aa557..de27256bf566c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -32,8 +32,7 @@ import { interface ConnectorAddModalProps { actionType: ActionType; - addModalVisible: boolean; - setAddModalVisibility: React.Dispatch>; + onClose: () => void; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; actionTypeRegistry: ActionTypeRegistryContract; @@ -48,8 +47,7 @@ interface ConnectorAddModalProps { export const ConnectorAddModal = ({ actionType, - addModalVisible, - setAddModalVisibility, + onClose, postSaveEventHandler, http, toastNotifications, @@ -79,14 +77,11 @@ export const ConnectorAddModal = ({ >(undefined); const closeModal = useCallback(() => { - setAddModalVisibility(false); setConnector(initialConnector); setServerError(undefined); - }, [initialConnector, setAddModalVisibility]); + onClose(); + }, [initialConnector, onClose]); - if (!addModalVisible) { - return null; - } const actionTypeModel = actionTypeRegistry.get(actionType.id); const errors = { ...actionTypeModel?.validateConnector(connector).errors, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 9a637ea750f81..20ad9a8d7c701 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect, Suspense } from 'react'; +import React, { Fragment, useState, useEffect, Suspense, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -153,9 +153,17 @@ export const AlertForm = ({ setAlertTypeModel(alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null); }, [alert, alertTypeRegistry]); - const setAlertProperty = (key: string, value: any) => { - dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); - }; + const setAlertProperty = useCallback( + (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }, + [dispatch] + ); + + const setActions = useCallback( + (updatedActions: AlertAction[]) => setAlertProperty('actions', updatedActions), + [setAlertProperty] + ); const setAlertParams = (key: string, value: any) => { dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); @@ -169,9 +177,12 @@ export const AlertForm = ({ dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; + const setActionParamsProperty = useCallback( + (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }, + [dispatch] + ); const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; @@ -202,6 +213,7 @@ export const AlertForm = ({ label={item.name} onClick={() => { setAlertProperty('alertTypeId', item.id); + setActions([]); setAlertTypeModel(item); setAlertProperty('params', {}); if (alertTypesIndex && alertTypesIndex.has(item.id)) { @@ -289,26 +301,25 @@ export const AlertForm = ({ /> ) : null} - {canShowActions && defaultActionGroupId ? ( + {canShowActions && + defaultActionGroupId && + alertTypeModel && + alertTypesIndex?.has(alert.alertTypeId) ? ( - a.name.toUpperCase().localeCompare(b.name.toUpperCase()) - ) - : undefined - } + messageVariables={actionVariablesFromAlertType( + alertTypesIndex.get(alert.alertTypeId)! + ).sort((a, b) => a.name.toUpperCase().localeCompare(b.name.toUpperCase()))} defaultActionGroupId={defaultActionGroupId} + actionGroups={alertTypesIndex.get(alert.alertTypeId)!.actionGroups} setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} - setAlertProperty={(updatedActions: AlertAction[]) => - setAlertProperty('actions', updatedActions) - } - setActionParamsProperty={(key: string, value: any, index: number) => - setActionParamsProperty(key, value, index) + setActionGroupIdByIndex={(group: string, index: number) => + setActionProperty('group', group, index) } + setAlertProperty={setActions} + setActionParamsProperty={setActionParamsProperty} http={http} actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7d99d3635106d..ee0de582a9bff 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -55,6 +55,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.click(); } + async function defineAlwaysFiringAlert(alertName: string) { + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await testSubjects.setValue('alertNameInput', alertName); + await testSubjects.click('test.always-firing-SelectOption'); + } + describe('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); @@ -106,6 +112,57 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); }); + it('should create an alert with actions in multiple groups', async () => { + const alertName = generateUniqueKey(); + await defineAlwaysFiringAlert(alertName); + + // create Slack connector and attach an action using it + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.click('addNewActionConnectorButton-.slack'); + const slackConnectorName = generateUniqueKey(); + await testSubjects.setValue('nameInput', slackConnectorName); + await testSubjects.setValue('slackWebhookUrlInput', 'https://test'); + await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); + const createdConnectorToastTitle = await pageObjects.common.closeToast(); + expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-0"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addAlertActionButton'); + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-1"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addNewActionConnectorActionGroup-1'); + await testSubjects.click('addNewActionConnectorActionGroup-1-option-other'); + + await testSubjects.click('saveAlertButton'); + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created alert "${alertName}"`); + await pageObjects.triggersActionsUI.searchAlerts(alertName); + const searchResultsAfterSave = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterSave).to.eql([ + { + name: alertName, + tagsText: '', + alertType: 'Always Firing', + interval: '1m', + }, + ]); + + // clean up created alert + const alertsToDelete = await getAlertsByName(alertName); + await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); + }); + it('should show save confirmation before creating alert with no actions', async () => { const alertName = generateUniqueKey(); await defineAlert(alertName); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index e3927f6bfffb9..6f9d010378624 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -78,6 +78,7 @@ function createAlwaysFiringAlertType(alerts: AlertingSetup) { { id: 'default', name: 'Default' }, { id: 'other', name: 'Other' }, ], + defaultActionGroupId: 'default', producer: 'alerts', async executor(alertExecutorOptions: any) { const { services, state, params } = alertExecutorOptions; From d1ef0d6704237cade5ff6a4246e42148dadb0b9e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 9 Nov 2020 13:11:51 +0000 Subject: [PATCH 7/9] skip flaky suite (#57426) --- .../functional_with_es_ssl/apps/triggers_actions_ui/details.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 9e4006681dc8d..1d86d95b7a796 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -306,7 +306,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - describe('Alert Instances', function () { + // FLAKY: https://github.com/elastic/kibana/issues/57426 + describe.skip('Alert Instances', function () { const testRunUuid = uuid.v4(); let alert: any; From f2f76e104af5d0515773c173475efda7d80c1d31 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 9 Nov 2020 14:29:53 +0100 Subject: [PATCH 8/9] [ILM] Migrate Delete phase and name field to Form Lib (#82834) * remove use of legacy state system and legacy serialization * remove legacy min_age input component and re-add missing import * rename shared -> shared_fields for more clarity * some more cleanup and fixing regressions on policy name for creating new policy from existing policy * move extract policy static code to lib folder and remove "policies" dir from services * fix jest tests and minor policy flyout inconsistency * remove legacy helper * fix client integration tests * fix min for set index priority * moved save policy function into edit policy section * remove unused translations * refactor form files to own edit_policy/form folder * remove "fix errors" badge to fix UX - users can see errors in line before pressing save so the value of this badge has diminished * fix i18n after removing phase error badge Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_policy/edit_policy.helpers.tsx | 5 + .../edit_policy/edit_policy.test.ts | 3 +- .../__jest__/components/edit_policy.test.tsx | 242 ++++---- .../public/application/lib/policies.ts | 32 ++ .../sections/edit_policy/components/index.ts | 3 - .../components/min_age_input_legacy.tsx | 263 --------- .../components/phase_error_message.tsx | 19 - .../phases/cold_phase/cold_phase.tsx | 23 +- .../{ => delete_phase}/delete_phase.tsx | 94 +-- .../components/phases/delete_phase/index.ts | 7 + .../components/phases/hot_phase/hot_phase.tsx | 12 +- .../components/cloud_data_tier_callout.tsx | 0 .../components/data_tier_allocation.scss | 0 .../components/data_tier_allocation.tsx | 0 .../components/default_allocation_notice.tsx | 0 .../components/index.ts | 0 .../components/no_node_attributes_warning.tsx | 0 .../components/node_allocation.tsx | 9 +- .../components/node_attrs_details.tsx | 0 .../components/node_data_provider.tsx | 0 .../components/types.ts | 0 .../data_tier_allocation_field.tsx | 0 .../data_tier_allocation_field/index.ts | 0 .../forcemerge_field.tsx | 6 +- .../phases/{shared => shared_fields}/index.ts | 2 + .../min_age_input_field/index.ts | 0 .../min_age_input_field.tsx | 0 .../min_age_input_field/util.ts | 0 .../set_priority_input.tsx | 9 +- .../snapshot_policies_field.tsx} | 113 ++-- .../phases/warm_phase/warm_phase.tsx | 27 +- .../components/policy_json_flyout.tsx | 22 +- .../edit_policy/edit_policy.container.tsx | 23 +- .../sections/edit_policy/edit_policy.tsx | 537 ++++++++---------- .../edit_policy/edit_policy_context.tsx | 12 +- .../edit_policy/{ => form}/deserializer.ts | 21 +- .../sections/edit_policy/form/index.ts | 13 + .../{form_schema.ts => form/schema.ts} | 53 +- .../edit_policy/{ => form}/serializer.ts | 24 +- .../validations.ts} | 77 ++- .../sections/edit_policy/i18n_texts.ts | 36 ++ .../edit_policy/save_policy.ts} | 15 +- .../application/sections/edit_policy/types.ts | 5 + .../services/policies/delete_phase.ts | 88 --- .../policies/policy_serialization.test.ts | 198 ------- .../services/policies/policy_serialization.ts | 82 --- .../services/policies/policy_validation.ts | 144 ----- .../public/shared_imports.ts | 3 + .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - 50 files changed, 749 insertions(+), 1485 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{ => delete_phase}/delete_phase.tsx (50%) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/cloud_data_tier_callout.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/data_tier_allocation.scss (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/data_tier_allocation.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/default_allocation_notice.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/index.ts (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/no_node_attributes_warning.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/node_allocation.tsx (90%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/node_attrs_details.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/node_data_provider.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/components/types.ts (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/data_tier_allocation_field.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/data_tier_allocation_field/index.ts (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/forcemerge_field.tsx (94%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/index.ts (88%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/min_age_input_field/index.ts (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/min_age_input_field/min_age_input_field.tsx (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/min_age_input_field/util.ts (100%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{shared => shared_fields}/set_priority_input.tsx (83%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/{snapshot_policies.tsx => phases/shared_fields/snapshot_policies_field.tsx} (68%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/{ => form}/deserializer.ts (82%) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/{form_schema.ts => form/schema.ts} (90%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/{ => form}/serializer.ts (90%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/{form_validations.ts => form/validations.ts} (50%) rename x-pack/plugins/index_lifecycle_management/public/application/{services/policies/policy_save.ts => sections/edit_policy/save_policy.ts} (84%) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 0b9f47e188d15..646978dd68153 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -221,6 +221,11 @@ export const setup = async () => { setFreeze, setIndexPriority: setIndexPriority('cold'), }, + delete: { + enable: enable('delete'), + setMinAgeValue: setMinAgeValue('delete'), + setMinAgeUnits: setMinAgeUnits('delete'), + }, }, }; }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 11fadf51f27f8..4ee67d1ed8a19 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -367,7 +367,6 @@ describe('', () => { expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ { label: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, - value: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, }, ]); }); @@ -412,7 +411,7 @@ describe('', () => { test('wait for snapshot field should delete action if field is empty', async () => { const { actions } = testBed; - actions.setWaitForSnapshotPolicy(''); + await actions.setWaitForSnapshotPolicy(''); await actions.savePolicy(); const expected = { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 4a3fedfb264ac..43910583ceec9 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -20,27 +20,27 @@ import { notificationServiceMock, fatalErrorsServiceMock, } from '../../../../../src/core/public/mocks'; + import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; + import { CloudSetup } from '../../../cloud/public'; import { EditPolicy } from '../../public/application/sections/edit_policy/edit_policy'; +import { + EditPolicyContextProvider, + EditPolicyContextValue, +} from '../../public/application/sections/edit_policy/edit_policy_context'; + import { KibanaContextProvider } from '../../public/shared_imports'; + import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; import { init as initNotification } from '../../public/application/services/notification'; import { PolicyFromES } from '../../common/types'; -import { - positiveNumberRequiredMessage, - policyNameRequiredMessage, - policyNameStartsWithUnderscoreErrorMessage, - policyNameContainsCommaErrorMessage, - policyNameContainsSpaceErrorMessage, - policyNameMustBeDifferentErrorMessage, - policyNameAlreadyUsedErrorMessage, -} from '../../public/application/services/policies/policy_validation'; import { i18nTexts } from '../../public/application/sections/edit_policy/i18n_texts'; import { editPolicyHelpers } from './helpers'; +import { defaultPolicy } from '../../public/application/constants'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -122,14 +122,11 @@ const noRollover = async (rendered: ReactWrapper) => { const getNodeAttributeSelect = (rendered: ReactWrapper, phase: string) => { return findTestSubject(rendered, `${phase}-selectedNodeAttrs`); }; -const setPolicyName = (rendered: ReactWrapper, policyName: string) => { +const setPolicyName = async (rendered: ReactWrapper, policyName: string) => { const policyNameField = findTestSubject(rendered, 'policyNameField'); - policyNameField.simulate('change', { target: { value: policyName } }); - rendered.update(); -}; -const setPhaseAfterLegacy = (rendered: ReactWrapper, phase: string, after: string | number) => { - const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); - afterInput.simulate('change', { target: { value: after } }); + await act(async () => { + policyNameField.simulate('change', { target: { value: policyName } }); + }); rendered.update(); }; const setPhaseAfter = async (rendered: ReactWrapper, phase: string, after: string | number) => { @@ -157,6 +154,32 @@ const save = async (rendered: ReactWrapper) => { }); rendered.update(); }; + +const MyComponent = ({ + isCloudEnabled, + isNewPolicy, + policy: _policy, + existingPolicies, + getUrlForApp, + policyName, +}: EditPolicyContextValue & { isCloudEnabled: boolean }) => { + return ( + + + + + + ); +}; + describe('edit policy', () => { beforeAll(() => { jest.useFakeTimers(); @@ -179,14 +202,14 @@ describe('edit policy', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); @@ -198,62 +221,78 @@ describe('edit policy', () => { test('should show error when trying to save empty form', async () => { const rendered = mountWithIntl(component); await save(rendered); - expectedErrorMessages(rendered, [policyNameRequiredMessage]); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameRequiredMessage]); }); test('should show error when trying to save policy name with space', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsSpaceErrorMessage]); + await setPolicyName(rendered, 'my policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name that is already used', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'testy0'); - rendered.update(); - await save(rendered); - expectedErrorMessages(rendered, [policyNameAlreadyUsedErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save as new policy but using the same name', async () => { component = ( - ); const rendered = mountWithIntl(component); findTestSubject(rendered, 'saveAsNewSwitch').simulate('click'); rendered.update(); - setPolicyName(rendered, 'testy0'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameMustBeDifferentErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save policy name with comma', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my,policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsCommaErrorMessage]); + await setPolicyName(rendered, 'my,policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name starting with underscore', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, '_mypolicy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameStartsWithUnderscoreErrorMessage]); + await setPolicyName(rendered, '_mypolicy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + ]); }); test('should show correct json in policy flyout', async () => { - const rendered = mountWithIntl(component); + const rendered = mountWithIntl( + + ); await act(async () => { findTestSubject(rendered, 'requestButton').simulate('click'); }); rendered.update(); + const json = rendered.find(`code`).text(); - const expected = `PUT _ilm/policy/\n${JSON.stringify( + const expected = `PUT _ilm/policy/my-policy\n${JSON.stringify( { policy: { phases: { @@ -282,7 +321,7 @@ describe('edit policy', () => { test('should show errors when trying to save with no max size and no max age', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '' } }); @@ -298,7 +337,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -309,7 +348,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -319,7 +358,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '-1' } }); @@ -329,7 +368,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '0' } }); @@ -337,21 +376,21 @@ describe('edit policy', () => { waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.numberGreatThan0Required]); }); - test('should show forcemerge input when rollover enabled', () => { + test('should show forcemerge input when rollover enabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeTruthy(); }); test('should hide forcemerge input when rollover is disabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await noRollover(rendered); waitForFormLibValidation(rendered); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeFalsy(); }); test('should show positive number required above zero error when trying to save hot phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); act(() => { findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); }); @@ -365,7 +404,7 @@ describe('edit policy', () => { }); test('should show positive number above 0 required error when trying to save hot phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); rendered.update(); const forcemergeInput = findTestSubject(rendered, 'hot-selectedForceMergeSegments'); @@ -379,7 +418,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await setPhaseIndexPriority(rendered, 'hot', '-1'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); @@ -397,7 +436,7 @@ describe('edit policy', () => { test('should show number required error when trying to save empty warm phase', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', ''); waitForFormLibValidation(rendered); @@ -406,7 +445,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '0'); waitForFormLibValidation(rendered); @@ -415,7 +454,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '-1'); waitForFormLibValidation(rendered); @@ -424,7 +463,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await setPhaseAfter(rendered, 'warm', '-1'); @@ -434,7 +473,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); act(() => { findTestSubject(rendered, 'shrinkSwitch').simulate('click'); @@ -451,7 +490,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -468,7 +507,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -485,7 +524,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await act(async () => { @@ -503,7 +542,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -517,7 +556,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -527,7 +566,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -539,7 +578,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -568,7 +607,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -581,7 +620,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -594,7 +633,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -611,7 +650,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '0'); waitForFormLibValidation(rendered); @@ -621,7 +660,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save cold phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '-1'); waitForFormLibValidation(rendered); @@ -631,7 +670,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -645,7 +684,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -655,7 +694,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -667,7 +706,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -689,7 +728,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '1'); await setPhaseIndexPriority(rendered, 'cold', '-1'); @@ -704,7 +743,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -717,7 +756,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -730,7 +769,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -740,20 +779,20 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '0'); - await save(rendered); + await setPhaseAfter(rendered, 'delete', '0'); + waitForFormLibValidation(rendered); expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '-1'); - await save(rendered); - expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + await setPhaseAfter(rendered, 'delete', '-1'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); }); }); describe('not on cloud', () => { @@ -768,7 +807,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -782,14 +821,13 @@ describe('edit policy', () => { describe('on cloud', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); ({ server, httpRequestsMockHelpers } = http); @@ -808,7 +846,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -829,7 +867,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -849,7 +887,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'cloudDataTierCallout').exists()).toBeTruthy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts new file mode 100644 index 0000000000000..c4a91978a3765 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PolicyFromES } from '../../../common/types'; + +export const splitSizeAndUnits = (field: string): { size: string; units: string } => { + let size = ''; + let units = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + units = result[2]; + } + + return { + size, + units, + }; +}; + +export const getPolicyByName = ( + policies: PolicyFromES[] | null | undefined, + policyName: string = '' +): PolicyFromES | undefined => { + if (policies && policies.length > 0) { + return policies.find((policy: PolicyFromES) => policy.name === policyName); + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index a04608338718e..326f6ff87dc3b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -7,11 +7,8 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; -export { MinAgeInput } from './min_age_input_legacy'; export { OptionalLabel } from './optional_label'; -export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; -export { SnapshotPolicies } from './snapshot_policies'; export { DescribedFormField } from './described_form_field'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx deleted file mode 100644 index 6fcf35b799289..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx +++ /dev/null @@ -1,263 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -import { PhaseWithMinAge, Phases } from '../../../../../common/types'; - -function getTimingLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel', { - defaultMessage: 'Timing for warm phase', - }); - - case 'cold': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel', { - defaultMessage: 'Timing for cold phase', - }); - - case 'delete': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { - defaultMessage: 'Timing for delete phase', - }); - } -} - -function getUnitsAriaLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of warm phase', - } - ); - - case 'cold': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of cold phase', - } - ); - - case 'delete': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of delete phase', - } - ); - } -} - -interface Props { - rolloverEnabled: boolean; - errors?: PhaseValidationErrors; - phase: keyof Phases & string; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} - -export const MinAgeInput = ({ - rolloverEnabled, - errors, - phaseData, - phase, - setPhaseData, - isShowingErrors, -}: React.PropsWithChildren>): React.ReactElement => { - let daysOptionLabel; - let hoursOptionLabel; - let minutesOptionLabel; - let secondsOptionLabel; - let millisecondsOptionLabel; - let microsecondsOptionLabel; - let nanosecondsOptionLabel; - - if (rolloverEnabled) { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel', - { - defaultMessage: 'days from rollover', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel', - { - defaultMessage: 'hours from rollover', - } - ); - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel', - { - defaultMessage: 'minutes from rollover', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel', - { - defaultMessage: 'seconds from rollover', - } - ); - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from rollover', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from rollover', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from rollover', - } - ); - } else { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel', - { - defaultMessage: 'days from index creation', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel', - { - defaultMessage: 'hours from index creation', - } - ); - - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel', - { - defaultMessage: 'minutes from index creation', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel', - { - defaultMessage: 'seconds from index creation', - } - ); - - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from index creation', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from index creation', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from index creation', - } - ); - } - - // check that these strings are valid properties - const selectedMinimumAgeProperty = propertyof('selectedMinimumAge'); - const selectedMinimumAgeUnitsProperty = propertyof('selectedMinimumAgeUnits'); - return ( - - - - } - /> - } - > - { - setPhaseData(selectedMinimumAgeProperty, e.target.value); - }} - min={0} - /> - - - - - setPhaseData(selectedMinimumAgeUnitsProperty, e.target.value)} - options={[ - { - value: 'd', - text: daysOptionLabel, - }, - { - value: 'h', - text: hoursOptionLabel, - }, - { - value: 'm', - text: minutesOptionLabel, - }, - { - value: 's', - text: secondsOptionLabel, - }, - { - value: 'ms', - text: millisecondsOptionLabel, - }, - { - value: 'micros', - text: microsecondsOptionLabel, - }, - { - value: 'nanos', - text: nanosecondsOptionLabel, - }, - ]} - /> - - - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx deleted file mode 100644 index 750f68543f221..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx +++ /dev/null @@ -1,19 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const PhaseErrorMessage = ({ isShowingErrors }: { isShowingErrors: boolean }) => { - return isShowingErrors ? ( - - - - ) : null; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 84e955a91ad7c..b87243bd1a9a1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -13,19 +13,13 @@ import { EuiDescribedFormGroup, EuiTextColor } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; -import { - useFormData, - useFormContext, - UseField, - ToggleField, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared'; +import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared_fields'; const i18nTexts = { dataTierAllocation: { @@ -43,15 +37,13 @@ const formFieldPaths = { }; export const ColdPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [formFieldPaths.enabled], }); const enabled = get(formData, formFieldPaths.enabled); - const isShowingErrors = form.isValid === false; return (
@@ -66,8 +58,7 @@ export const ColdPhase: FunctionComponent = () => { defaultMessage="Cold phase" />

{' '} - {enabled && !isShowingErrors ? : null} - + {enabled && } } titleSize="s" @@ -128,9 +119,7 @@ export const ColdPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.cold?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.cold?.actions?.allocate?.number_of_replicas), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 78ae66327654c..37323b97edc92 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -7,53 +7,24 @@ import React, { FunctionComponent, Fragment } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { DeletePhase as DeletePhaseInterface, Phases } from '../../../../../../common/types'; +import { useFormData, UseField, ToggleField } from '../../../../../../shared_imports'; -import { useFormData } from '../../../../../shared_imports'; +import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; -import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { MinAgeInputField, SnapshotPoliciesField } from '../shared_fields'; -import { - ActiveBadge, - LearnMoreLink, - OptionalLabel, - PhaseErrorMessage, - MinAgeInput, - SnapshotPolicies, -} from '../'; -import { useRolloverPath } from './shared'; - -const deleteProperty: keyof Phases = 'delete'; -const phaseProperty = (propertyName: keyof DeletePhaseInterface) => propertyName; - -interface Props { - setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; - phaseData: DeletePhaseInterface; - isShowingErrors: boolean; - errors?: PhaseValidationErrors; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; -} +const formFieldPaths = { + enabled: '_meta.delete.enabled', +}; -export const DeletePhase: FunctionComponent = ({ - setPhaseData, - phaseData, - errors, - isShowingErrors, - getUrlForApp, -}) => { +export const DeletePhase: FunctionComponent = () => { const [formData] = useFormData({ - watch: useRolloverPath, + watch: formFieldPaths.enabled, }); - const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + const enabled = get(formData, formFieldPaths.enabled); return (
@@ -66,8 +37,7 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="Delete phase" /> {' '} - {phaseData.phaseEnabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -79,39 +49,23 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="You no longer need your index. You can define when it is safe to delete it." />

- - } - id={`${deleteProperty}-${phaseProperty('phaseEnabled')}`} - checked={phaseData.phaseEnabled} - onChange={(e) => { - setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); +
} fullWidth > - {phaseData.phaseEnabled ? ( - - errors={errors} - phaseData={phaseData} - phase={deleteProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - ) : ( -
- )} + {enabled && } - {phaseData.phaseEnabled ? ( + {enabled ? ( @@ -145,11 +99,7 @@ export const DeletePhase: FunctionComponent = ({ } > - setPhaseData(phaseProperty('waitForSnapshotPolicy'), value)} - getUrlForApp={getUrlForApp} - /> + ) : null} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts new file mode 100644 index 0000000000000..488e4e26cfce0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a184ddf5148b9..629c1388f61fb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -19,7 +19,6 @@ import { import { Phases } from '../../../../../../../common/types'; import { - useFormContext, useFormData, UseField, SelectField, @@ -29,26 +28,24 @@ import { import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION } from '../../../form_validations'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { ROLLOVER_FORM_PATHS } from '../../../constants'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage } from '../../'; +import { LearnMoreLink, ActiveBadge } from '../../'; -import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared'; +import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared_fields'; import { maxSizeStoredUnits, maxAgeUnits } from './constants'; const hotProperty: keyof Phases = 'hot'; export const HotPhase: FunctionComponent = () => { - const form = useFormContext(); const [formData] = useFormData({ watch: useRolloverPath, }); const isRolloverEnabled = get(formData, useRolloverPath); - const isShowingErrors = form.isValid === false; const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); return ( @@ -62,8 +59,7 @@ export const HotPhase: FunctionComponent = () => { defaultMessage="Hot phase" /> {' '} - {isShowingErrors ? null : } - +
} titleSize="s" diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx index 407bb9ea92e85..c1676d7074dbc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx @@ -10,12 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; -import { PhaseWithAllocationAction } from '../../../../../../../../../common/types'; - import { UseField, SelectField, useFormData } from '../../../../../../../../shared_imports'; -import { propertyof } from '../../../../../../../services/policies/policy_validation'; - import { LearnMoreLink } from '../../../../learn_more_link'; import { NodeAttrsDetails } from './node_attrs_details'; @@ -61,9 +57,6 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - return ( <> @@ -100,7 +93,7 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) ) : undefined, euiFieldProps: { - 'data-test-subj': `${phase}-${nodeAttrsProperty}`, + 'data-test-subj': `${phase}-selectedNodeAttrs`, options: [{ text: i18nTexts.doNotModifyAllocationOption, value: '' }].concat( nodeOptions ), diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx similarity index 94% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index b410bd0e6b3b0..b05d49be497cd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -21,11 +21,11 @@ interface Props { } export const Forcemerge: React.FunctionComponent = ({ phase }) => { - const { originalPolicy } = useEditPolicyContext(); + const { policy } = useEditPolicyContext(); const initialToggleValue = useMemo(() => { - return Boolean(originalPolicy.phases[phase]?.actions?.forcemerge); - }, [originalPolicy, phase]); + return Boolean(policy.phases[phase]?.actions?.forcemerge); + }, [policy, phase]); return ( = ({ phase }) => { - const phaseIndexPriorityProperty = propertyof('phaseIndexPriority'); return ( = ({ phase }) => { componentProps={{ fullWidth: false, euiFieldProps: { - 'data-test-subj': `${phase}-${phaseIndexPriorityProperty}`, - min: 1, + 'data-test-subj': `${phase}-phaseIndexPriority`, + min: 0, }, }} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx similarity index 68% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index cc2849b5c8e9c..e9f9f331e410a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -4,52 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; - +import React from 'react'; +import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ApplicationStart } from 'kibana/public'; import { EuiButtonIcon, EuiCallOut, - EuiComboBox, EuiComboBoxOptionOption, EuiLink, EuiSpacer, } from '@elastic/eui'; -import { useLoadSnapshotPolicies } from '../../../services/api'; +import { UseField, ComboBoxField, useFormData } from '../../../../../../shared_imports'; +import { useLoadSnapshotPolicies } from '../../../../../services/api'; +import { useEditPolicyContext } from '../../../edit_policy_context'; + +const waitForSnapshotFormField = 'phases.delete.actions.wait_for_snapshot.policy'; -interface Props { - value: string; - onChange: (value: string) => void; - getUrlForApp: ApplicationStart['getUrlForApp']; -} -export const SnapshotPolicies: React.FunctionComponent = ({ - value, - onChange, - getUrlForApp, -}) => { +export const SnapshotPoliciesField: React.FunctionComponent = () => { + const { getUrlForApp } = useEditPolicyContext(); const { error, isLoading, data, resendRequest } = useLoadSnapshotPolicies(); + const [formData] = useFormData({ + watch: waitForSnapshotFormField, + }); + + const selectedSnapshotPolicy = get(formData, waitForSnapshotFormField); const policies = data.map((name: string) => ({ label: name, value: name, })); - const onComboChange = (options: EuiComboBoxOptionOption[]) => { - if (options.length > 0) { - onChange(options[0].label); - } else { - onChange(''); - } - }; - - const onCreateOption = (newValue: string) => { - onChange(newValue); - }; - const getUrlForSnapshotPolicyWizard = () => { return getUrlForApp('management', { path: `data/snapshot_restore/add_policy`, @@ -59,14 +46,14 @@ export const SnapshotPolicies: React.FunctionComponent = ({ let calloutContent; if (error) { calloutContent = ( - + <> + <> = ({ } )} /> - + } > = ({ defaultMessage="Refresh this field and enter the name of an existing snapshot policy." /> - + ); } else if (data.length === 0) { calloutContent = ( - + <> = ({ }} /> - + ); - } else if (value && !data.includes(value)) { + } else if (selectedSnapshotPolicy && !data.includes(selectedSnapshotPolicy)) { calloutContent = ( - + <> = ({ }} /> - + ); } return ( - - + path={waitForSnapshotFormField}> + {(field) => { + const singleSelectionArray: [selectedSnapshot?: string] = field.value + ? [field.value] + : []; + + return ( + { + field.setValue(newOption); }, - ] - : [] - } - onChange={onComboChange} - noSuggestions={!!(error || data.length === 0)} - /> + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + field.setValue(options[0].label); + } else { + field.setValue(''); + } + }, + }} + /> + ); + }} + {calloutContent} - + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index 06c16e8bdd5ab..94fd2ee9edaca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -17,23 +17,17 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - useFormData, - UseField, - ToggleField, - useFormContext, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { Phases } from '../../../../../../../common/types'; -import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared'; +import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared_fields'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { DataTierAllocationField } from '../shared'; +import { DataTierAllocationField } from '../shared_fields'; const i18nTexts = { shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { @@ -54,8 +48,7 @@ const formFieldPaths = { }; export const WarmPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [useRolloverPath, formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], }); @@ -63,7 +56,6 @@ export const WarmPhase: FunctionComponent = () => { const enabled = get(formData, formFieldPaths.enabled); const hotPhaseRolloverEnabled = get(formData, useRolloverPath); const warmPhaseOnRollover = get(formData, formFieldPaths.warmPhaseOnRollover); - const isShowingErrors = form.isValid === false; return (
@@ -77,8 +69,7 @@ export const WarmPhase: FunctionComponent = () => { defaultMessage="Warm phase" /> {' '} - {enabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -161,9 +152,7 @@ export const WarmPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.warm?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.warm?.actions?.allocate?.number_of_replicas), }} fullWidth > @@ -203,7 +192,7 @@ export const WarmPhase: FunctionComponent = () => { 'data-test-subj': 'shrinkSwitch', label: i18nTexts.shrinkLabel, 'aria-label': i18nTexts.shrinkLabel, - initialValue: Boolean(originalPolicy.phases.warm?.actions?.shrink), + initialValue: Boolean(policy.phases.warm?.actions?.shrink), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx index 7098b018d6dfd..a8b1680ebde07 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiButtonEmpty, EuiCodeBlock, @@ -25,19 +24,15 @@ import { import { SerializedPolicy } from '../../../../../common/types'; import { useFormContext, useFormData } from '../../../../shared_imports'; + import { FormInternal } from '../types'; interface Props { - legacyPolicy: SerializedPolicy; close: () => void; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ - policyName, - close, - legacyPolicy, -}) => { +export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, close }) => { /** * policy === undefined: we are checking validity * policy === null: we have determined the policy is invalid @@ -51,20 +46,11 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ const updatePolicy = useCallback(async () => { setPolicy(undefined); if (await validateForm()) { - const p = getFormData() as SerializedPolicy; - setPolicy({ - ...legacyPolicy, - phases: { - ...legacyPolicy.phases, - hot: p.phases.hot, - warm: p.phases.warm, - cold: p.phases.cold, - }, - }); + setPolicy(getFormData() as SerializedPolicy); } else { setPolicy(null); } - }, [setPolicy, getFormData, legacyPolicy, validateForm]); + }, [setPolicy, getFormData, validateForm]); useEffect(() => { updatePolicy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index c82a420b74857..ebef80871b83d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -12,8 +12,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useKibana } from '../../../shared_imports'; import { useLoadPoliciesList } from '../../services/api'; +import { getPolicyByName } from '../../lib/policies'; +import { defaultPolicy } from '../../constants'; import { EditPolicy as PresentationComponent } from './edit_policy'; +import { EditPolicyContextProvider } from './edit_policy_context'; interface RouterProps { policyName: string; @@ -44,6 +47,7 @@ export const EditPolicy: React.FunctionComponent { breadcrumbService.setBreadcrumbs('editPolicy'); }, [breadcrumbService]); + if (isLoading) { return ( + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 5397f5da2d6bb..1abbe884c2dc2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { Fragment, useEffect, useState, useMemo } from 'react'; +import { get } from 'lodash'; import { RouteComponentProps } from 'react-router-dom'; @@ -16,7 +17,6 @@ import { EuiButton, EuiButtonEmpty, EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -30,31 +30,13 @@ import { EuiTitle, } from '@elastic/eui'; -import { useForm, Form } from '../../../shared_imports'; +import { useForm, Form, UseField, TextField, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; -import { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultPolicy } from '../../constants'; - -import { - validatePolicy, - ValidationErrors, - findFirstError, -} from '../../services/policies/policy_validation'; - -import { savePolicy } from '../../services/policies/policy_save'; +import { savePolicy } from './save_policy'; import { - deserializePolicy, - getPolicyByName, - initializeNewPolicy, - legacySerializePolicy, -} from '../../services/policies/policy_serialization'; - -import { - ErrableFormRow, LearnMoreLink, PolicyJsonFlyout, ColdPhase, @@ -63,93 +45,66 @@ import { WarmPhase, } from './components'; -import { schema } from './form_schema'; -import { deserializer } from './deserializer'; -import { createSerializer } from './serializer'; +import { schema, deserializer, createSerializer, createPolicyNameValidations } from './form'; -import { EditPolicyContextProvider } from './edit_policy_context'; +import { useEditPolicyContext } from './edit_policy_context'; +import { FormInternal } from './types'; export interface Props { - policies: PolicyFromES[]; - policyName: string; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; history: RouteComponentProps['history']; } -const mergeAllSerializedPolicies = ( - serializedPolicy: SerializedPolicy, - legacySerializedPolicy: SerializedPolicy -): SerializedPolicy => { - return { - ...legacySerializedPolicy, - phases: { - ...legacySerializedPolicy.phases, - hot: serializedPolicy.phases.hot, - warm: serializedPolicy.phases.warm, - cold: serializedPolicy.phases.cold, - }, - }; -}; +const policyNamePath = 'name'; -export const EditPolicy: React.FunctionComponent = ({ - policies, - policyName, - history, - getUrlForApp, -}) => { +export const EditPolicy: React.FunctionComponent = ({ history }) => { useEffect(() => { window.scrollTo(0, 0); }, []); - const [isShowingErrors, setIsShowingErrors] = useState(false); - const [errors, setErrors] = useState(); const [isShowingPolicyJsonFlyout, setIsShowingPolicyJsonFlyout] = useState(false); - - const existingPolicy = getPolicyByName(policies, policyName); + const { + isNewPolicy, + policy: currentPolicy, + existingPolicies, + policyName, + } = useEditPolicyContext(); const serializer = useMemo(() => { - return createSerializer(existingPolicy?.policy); - }, [existingPolicy?.policy]); + return createSerializer(isNewPolicy ? undefined : currentPolicy); + }, [isNewPolicy, currentPolicy]); - const originalPolicy = existingPolicy?.policy ?? defaultPolicy; + const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const originalPolicyName: string = isNewPolicy ? '' : policyName!; const { form } = useForm({ schema, - defaultValue: originalPolicy, + defaultValue: { + ...currentPolicy, + name: originalPolicyName, + }, deserializer, serializer, }); - const [policy, setPolicy] = useState(() => - existingPolicy ? deserializePolicy(existingPolicy) : initializeNewPolicy(policyName) + const [formData] = useFormData({ form, watch: policyNamePath }); + const currentPolicyName = get(formData, policyNamePath); + + const policyNameValidations = useMemo( + () => + createPolicyNameValidations({ + originalPolicyName, + policies: existingPolicies, + saveAsNewPolicy: saveAsNew, + }), + [originalPolicyName, existingPolicies, saveAsNew] ); - const isNewPolicy: boolean = !Boolean(existingPolicy); - const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); - const originalPolicyName: string = existingPolicy ? existingPolicy.name : ''; - const backToPolicyList = () => { history.push('/policies'); }; const submit = async () => { - setIsShowingErrors(true); - const { data: formLibPolicy, isValid: newIsValid } = await form.submit(); - const [legacyIsValid, validationErrors] = validatePolicy( - saveAsNew, - policy, - policies, - originalPolicyName - ); - setErrors(validationErrors); - - const isValid = legacyIsValid && newIsValid; + const { data: policy, isValid } = await form.submit(); if (!isValid) { toasts.addDanger( @@ -157,22 +112,11 @@ export const EditPolicy: React.FunctionComponent = ({ defaultMessage: 'Please fix the errors on this page.', }) ); - // This functionality will not be required for once form lib is fully adopted for this form - // because errors are reported as fields are edited. - if (!legacyIsValid) { - const firstError = findFirstError(validationErrors); - const errorRowId = `${firstError ? firstError.replace('.', '-') : ''}-row`; - const element = document.getElementById(errorRowId); - if (element) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } - } } else { - const readSerializedPolicy = () => { - const legacySerializedPolicy = legacySerializePolicy(policy, existingPolicy?.policy); - return mergeAllSerializedPolicies(formLibPolicy, legacySerializedPolicy); - }; - const success = await savePolicy(readSerializedPolicy, isNewPolicy || saveAsNew); + const success = await savePolicy( + { ...policy, name: saveAsNew ? currentPolicyName : originalPolicyName }, + isNewPolicy || saveAsNew + ); if (success) { backToPolicyList(); } @@ -183,248 +127,217 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = useCallback( - (phase: keyof LegacyPolicy['phases'], key: string, value: any) => { - setPolicy((nextPolicy) => ({ - ...nextPolicy, - phases: { - ...nextPolicy.phases, - [phase]: { ...nextPolicy.phases[phase], [key]: value }, - }, - })); - }, - [setPolicy] - ); - - const setDeletePhaseData = useCallback( - (key: string, value: any) => setPhaseData('delete', key, value), - [setPhaseData] - ); - return ( - - - - - -

- {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} -

-
- -
-
- - -

- + +

+ {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create an index lifecycle policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + values: { originalPolicyName }, + })} +

+ + +
+ + + +

+ {' '} - - } - /> -

-
+ />{' '} + + } + /> +

+ - + - {isNewPolicy ? null : ( - - -

- - - - .{' '} + {isNewPolicy ? null : ( + + +

+ + + .{' '} + -

-
- - - - { - setSaveAsNew(e.target.checked); - }} - label={ - - - - } /> - -
- )} - - {saveAsNew || isNewPolicy ? ( - - +

+ + + + + { + setSaveAsNew(e.target.checked); + }} + label={ + -
- } - titleSize="s" - fullWidth - > - + + + )} + + {saveAsNew || isNewPolicy ? ( + + - } - > - { - setPolicy({ ...policy, name: e.target.value }); - }} - /> - - - ) : null} + +
+ } + titleSize="s" + fullWidth + > + + path={policyNamePath} + config={{ + label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameLabel', { + defaultMessage: 'Policy name', + }), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage', + { + defaultMessage: + 'A policy name cannot start with an underscore and cannot contain a question mark or a space.', + } + ), + validations: policyNameValidations, + }} + component={TextField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': 'policyNameField', + }, + }} + /> +
+ ) : null} - + - + - + - + - + - + - + - 0 - } - getUrlForApp={getUrlForApp} - setPhaseData={setDeletePhaseData} - phaseData={policy.phases.delete} - /> + - - - - - - - - {saveAsNew ? ( - - ) : ( - - )} - - - - - + + + + + + + + {saveAsNew ? ( - - - - - - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( + ) : ( + + )} + + + + + - )} - - - - - {isShowingPolicyJsonFlyout ? ( - setIsShowingPolicyJsonFlyout(false)} - /> - ) : null} - - - - - - + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + + + + {isShowingPolicyJsonFlyout ? ( + setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} + + + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx index 4748c26d6cec1..da5f940b1b6c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx @@ -5,10 +5,16 @@ */ import React, { createContext, ReactChild, useContext } from 'react'; -import { SerializedPolicy } from '../../../../common/types'; +import { ApplicationStart } from 'kibana/public'; -interface EditPolicyContextValue { - originalPolicy: SerializedPolicy; +import { PolicyFromES, SerializedPolicy } from '../../../../common/types'; + +export interface EditPolicyContextValue { + isNewPolicy: boolean; + policy: SerializedPolicy; + existingPolicies: PolicyFromES[]; + getUrlForApp: ApplicationStart['getUrlForApp']; + policyName?: string; } const EditPolicyContext = createContext(null as any); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts similarity index 82% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index f0294a5391d21..5af8807f2dec8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -6,17 +6,17 @@ import { produce } from 'immer'; -import { SerializedPolicy } from '../../../../common/types'; +import { SerializedPolicy } from '../../../../../common/types'; -import { splitSizeAndUnits } from '../../services/policies/policy_serialization'; +import { splitSizeAndUnits } from '../../../lib/policies'; -import { determineDataTierAllocationType } from '../../lib'; +import { determineDataTierAllocationType } from '../../../lib'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; export const deserializer = (policy: SerializedPolicy): FormInternal => { const { - phases: { hot, warm, cold }, + phases: { hot, warm, cold, delete: deletePhase }, } = policy; const _meta: FormInternal['_meta'] = { @@ -37,6 +37,9 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { dataTierAllocationType: determineDataTierAllocationType(cold?.actions), freezeEnabled: Boolean(cold?.actions?.freeze), }, + delete: { + enabled: Boolean(deletePhase), + }, }; return produce( @@ -86,6 +89,14 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { draft._meta.cold.minAgeUnit = minAge.units; } } + + if (draft.phases.delete) { + if (draft.phases.delete.min_age) { + const minAge = splitSizeAndUnits(draft.phases.delete.min_age); + draft.phases.delete.min_age = minAge.size; + draft._meta.delete.minAgeUnit = minAge.units; + } + } } ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts new file mode 100644 index 0000000000000..82fa478832582 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { deserializer } from './deserializer'; + +export { createSerializer } from './serializer'; + +export { schema } from './schema'; + +export * from './validations'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 070f03f74b954..4d20db4018740 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -6,18 +6,19 @@ import { i18n } from '@kbn/i18n'; -import { FormSchema, fieldValidators } from '../../../shared_imports'; -import { defaultSetPriority, defaultPhaseIndexPriority } from '../../constants'; +import { FormSchema, fieldValidators } from '../../../../shared_imports'; +import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, rolloverThresholdsValidator, -} from './form_validations'; + minAgeValidator, +} from './validations'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; const { emptyField, numberGreaterThanField } = fieldValidators; @@ -97,6 +98,18 @@ export const schema: FormSchema = { label: i18nTexts.editPolicy.allocationNodeAttributeFieldLabel, }, }, + delete: { + enabled: { + defaultValue: false, + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.activateWarmPhaseSwitchLabel', + { defaultMessage: 'Activate delete phase' } + ), + }, + minAgeUnit: { + defaultValue: 'd', + }, + }, }, phases: { hot: { @@ -177,15 +190,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -256,15 +261,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -292,5 +289,15 @@ export const schema: FormSchema = { }, }, }, + delete: { + min_age: { + defaultValue: '0', + validations: [ + { + validator: minAgeValidator, + }, + ], + }, + }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts index 564b5a2c4e397..2274efda426ad 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; +import { isEmpty, isNumber } from 'lodash'; -import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../common/types'; +import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../../common/types'; -import { FormInternal, DataAllocationMetaFields } from './types'; -import { isNumber } from '../../services/policies/policy_serialization'; +import { FormInternal, DataAllocationMetaFields } from '../types'; const serializeAllocateAction = ( { dataTierAllocationType, allocationNodeAttribute }: DataAllocationMetaFields, @@ -165,5 +164,22 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( } } + /** + * DELETE PHASE SERIALIZATION + */ + if (policy.phases.delete) { + if (policy.phases.delete.min_age) { + policy.phases.delete.min_age = `${policy.phases.delete.min_age}${_meta.delete.minAgeUnit}`; + } + + if (originalPolicy?.phases.delete?.actions) { + const { wait_for_snapshot: __, ...rest } = originalPolicy.phases.delete.actions; + policy.phases.delete.actions = { + ...policy.phases.delete.actions, + ...rest, + }; + } + } + return policy; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index 9c855ccb41624..f2e26a552efc9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fieldValidators, ValidationFunc } from '../../../shared_imports'; +import { fieldValidators, ValidationFunc, ValidationConfig } from '../../../../shared_imports'; -import { ROLLOVER_FORM_PATHS } from './constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; +import { PolicyFromES } from '../../../../../common/types'; +import { FormInternal } from '../types'; -const { numberGreaterThanField } = fieldValidators; +const { numberGreaterThanField, containsCharsField, emptyField, startsWithField } = fieldValidators; const createIfNumberExistsValidator = ({ than, @@ -46,7 +48,7 @@ export const ifExistsNumberNonNegative = createIfNumberExistsValidator({ * A special validation type used to keep track of validation errors for * the rollover threshold values not being set (e.g., age and doc count) */ -export const ROLLOVER_EMPTY_VALIDATION = 'EMPTY'; +export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; /** * An ILM policy requires that for rollover a value must be set for one of the threshold values. @@ -87,3 +89,68 @@ export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { fields[ROLLOVER_FORM_PATHS.maxSize].clearErrors(ROLLOVER_EMPTY_VALIDATION); } }; + +export const minAgeValidator: ValidationFunc = (arg) => + numberGreaterThanField({ + than: 0, + allowEquality: true, + message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, + })({ + ...arg, + value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), + }); + +export const createPolicyNameValidations = ({ + policies, + saveAsNewPolicy, + originalPolicyName, +}: { + policies: PolicyFromES[]; + saveAsNewPolicy: boolean; + originalPolicyName?: string; +}): Array> => { + return [ + { + validator: emptyField(i18nTexts.editPolicy.errors.policyNameRequiredMessage), + }, + { + validator: startsWithField({ + message: i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + char: '_', + }), + }, + { + validator: containsCharsField({ + message: i18nTexts.editPolicy.errors.policyNameContainsInvalidChars, + chars: [',', ' '], + }), + }, + { + validator: (arg) => { + const policyName = arg.value; + if (window.TextEncoder && new window.TextEncoder().encode(policyName).length > 255) { + return { + message: i18nTexts.editPolicy.errors.policyNameTooLongErrorMessage, + }; + } + }, + }, + { + validator: (arg) => { + const policyName = arg.value; + if (saveAsNewPolicy && policyName === originalPolicyName) { + return { + message: i18nTexts.editPolicy.errors.policyNameMustBeDifferentErrorMessage, + }; + } else if (policyName !== originalPolicyName) { + const policyNames = policies.map((existingPolicy) => existingPolicy.name); + if (policyNames.includes(policyName)) { + return { + message: i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + }; + } + } + }, + }, + ]; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 1fba69b7634ae..ccd5d3a568fe3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -98,6 +98,42 @@ export const i18nTexts = { defaultMessage: 'Only non-negative numbers are allowed.', } ), + policyNameContainsInvalidChars: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.errors.policyNameContainsInvalidCharsError', + { + defaultMessage: 'A policy name cannot contain spaces or commas.', + } + ), + policyNameAlreadyUsedErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', + { + defaultMessage: 'That policy name is already used.', + } + ), + policyNameMustBeDifferentErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', + { + defaultMessage: 'The policy name must be different.', + } + ), + policyNameRequiredMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', + { + defaultMessage: 'A policy name is required.', + } + ), + policyNameStartsWithUnderscoreErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', + { + defaultMessage: 'A policy name cannot start with an underscore.', + } + ), + policyNameTooLongErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', + { + defaultMessage: 'A policy name cannot be longer than 255 bytes.', + } + ), }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts similarity index 84% rename from x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts index 9cf622e830cb2..e2ab6a8817ef6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts @@ -3,23 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; import { SerializedPolicy } from '../../../../common/types'; -import { savePolicy as savePolicyApi } from '../api'; -import { showApiError } from '../api_errors'; -import { getUiMetricsForPhases, trackUiMetric } from '../ui_metric'; + import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants'; -import { toasts } from '../notification'; + +import { toasts } from '../../services/notification'; +import { savePolicy as savePolicyApi } from '../../services/api'; +import { getUiMetricsForPhases, trackUiMetric } from '../../services/ui_metric'; +import { showApiError } from '../../services/api_errors'; export const savePolicy = async ( - readSerializedPolicy: () => SerializedPolicy, + serializedPolicy: SerializedPolicy, isNew: boolean ): Promise => { - const serializedPolicy = readSerializedPolicy(); - try { await savePolicyApi(serializedPolicy); } catch (err) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 1884f8dbc0619..dc3d8a640e682 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -38,6 +38,10 @@ interface ColdPhaseMetaFields extends DataAllocationMetaFields, MinAgeField { freezeEnabled: boolean; } +interface DeletePhaseMetaFields extends MinAgeField { + enabled: boolean; +} + /** * Describes the shape of data after deserialization. */ @@ -50,5 +54,6 @@ export interface FormInternal extends SerializedPolicy { hot: HotPhaseMetaFields; warm: WarmPhaseMetaFields; cold: ColdPhaseMetaFields; + delete: DeletePhaseMetaFields; }; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts deleted file mode 100644 index 6ada039d45cd9..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts +++ /dev/null @@ -1,88 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DeletePhase, SerializedDeletePhase } from '../../../../common/types'; -import { serializedPhaseInitialization } from '../../constants'; -import { isNumber, splitSizeAndUnits } from './policy_serialization'; -import { - numberRequiredMessage, - PhaseValidationErrors, - positiveNumberRequiredMessage, -} from './policy_validation'; - -const deletePhaseInitialization: DeletePhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - waitForSnapshotPolicy: '', -}; - -export const deletePhaseFromES = (phaseSerialized?: SerializedDeletePhase): DeletePhase => { - const phase = { ...deletePhaseInitialization }; - if (phaseSerialized === undefined || phaseSerialized === null) { - return phase; - } - - phase.phaseEnabled = true; - if (phaseSerialized.min_age) { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); - phase.selectedMinimumAge = minAge; - phase.selectedMinimumAgeUnits = minAgeUnits; - } - - if (phaseSerialized.actions) { - const actions = phaseSerialized.actions; - - if (actions.wait_for_snapshot) { - phase.waitForSnapshotPolicy = actions.wait_for_snapshot.policy; - } - } - - return phase; -}; - -export const deletePhaseToES = ( - phase: DeletePhase, - originalEsPhase?: SerializedDeletePhase -): SerializedDeletePhase => { - if (!originalEsPhase) { - originalEsPhase = { ...serializedPhaseInitialization }; - } - const esPhase = { ...originalEsPhase }; - - if (isNumber(phase.selectedMinimumAge)) { - esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; - } - - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.waitForSnapshotPolicy) { - esPhase.actions.wait_for_snapshot = { - policy: phase.waitForSnapshotPolicy, - }; - } else { - delete esPhase.actions.wait_for_snapshot; - } - - return esPhase; -}; - -export const validateDeletePhase = (phase: DeletePhase): PhaseValidationErrors => { - if (!phase.phaseEnabled) { - return {}; - } - - const phaseErrors = {} as PhaseValidationErrors; - - // min age needs to be a positive number - if (!isNumber(phase.selectedMinimumAge)) { - phaseErrors.selectedMinimumAge = [numberRequiredMessage]; - } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { - phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; - } - - return { ...phaseErrors }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts deleted file mode 100644 index 19481b39a2c80..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts +++ /dev/null @@ -1,198 +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; - * you may not use this file except in compliance with the Elastic License. - */ -// Prefer importing entire lodash library, e.g. import { get } from "lodash" -// eslint-disable-next-line no-restricted-imports -import cloneDeep from 'lodash/cloneDeep'; -import { deserializePolicy, legacySerializePolicy } from './policy_serialization'; -import { defaultNewDeletePhase } from '../../constants'; - -describe('Policy serialization', () => { - test('serialize a policy using "default" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "none" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialization does not alter the original policy', () => { - const originalPolicy = { - name: 'test', - phases: {}, - }; - - const originalClone = cloneDeep(originalPolicy); - - const deserializedPolicy = { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; - - legacySerializePolicy(deserializedPolicy, originalPolicy); - expect(originalPolicy).toEqual(originalClone); - }); - - test('serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('de-serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - deserializePolicy({ - modified_date: Date.now().toString(), - name: 'test', - version: 1, - policy: { - name: 'test', - phases: { - hot: { - actions: { - rollover: { - max_age: '30d', - max_size: '50gb', - }, - forcemerge: { - max_num_segments: 1, - index_codec: 'best_compression', - }, - set_priority: { - priority: 100, - }, - }, - }, - }, - }, - }) - ).toEqual({ - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }); - }); - - test('delete "best_compression" codec for forcemerge if disabled in UI', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: {}, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); -}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts deleted file mode 100644 index 55e9d88dcd383..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ /dev/null @@ -1,82 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultNewDeletePhase, serializedPhaseInitialization } from '../../constants'; - -import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; - -export const splitSizeAndUnits = (field: string): { size: string; units: string } => { - let size = ''; - let units = ''; - - const result = /(\d+)(\w+)/.exec(field); - if (result) { - size = result[1]; - units = result[2]; - } - - return { - size, - units, - }; -}; - -export const isNumber = (value: any): boolean => value !== '' && value !== null && isFinite(value); - -export const getPolicyByName = ( - policies: PolicyFromES[] | null | undefined, - policyName: string = '' -): PolicyFromES | undefined => { - if (policies && policies.length > 0) { - return policies.find((policy: PolicyFromES) => policy.name === policyName); - } -}; - -export const initializeNewPolicy = (newPolicyName: string = ''): LegacyPolicy => { - return { - name: newPolicyName, - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; -}; - -export const deserializePolicy = (policy: PolicyFromES): LegacyPolicy => { - const { - name, - policy: { phases }, - } = policy; - - return { - name, - phases: { - delete: deletePhaseFromES(phases.delete), - }, - }; -}; - -export const legacySerializePolicy = ( - policy: LegacyPolicy, - originalEsPolicy: SerializedPolicy = { - name: policy.name, - phases: { hot: { ...serializedPhaseInitialization } }, - } -): SerializedPolicy => { - const serializedPolicy = { - name: policy.name, - phases: {}, - } as SerializedPolicy; - - if (policy.phases.delete.phaseEnabled) { - serializedPolicy.phases.delete = deletePhaseToES( - policy.phases.delete, - originalEsPolicy.phases.delete - ); - } - return serializedPolicy; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts deleted file mode 100644 index 79c909c433f33..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ /dev/null @@ -1,144 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { DeletePhase, LegacyPolicy, PolicyFromES } from '../../../../common/types'; -import { validateDeletePhase } from './delete_phase'; - -export const propertyof = (propertyName: keyof T & string) => propertyName; - -export const numberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', - { - defaultMessage: 'A number is required.', - } -); - -// TODO validation includes 0 -> should be non-negative number? -export const positiveNumberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', - { - defaultMessage: 'Only positive numbers are allowed.', - } -); - -export const positiveNumbersAboveZeroErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', - { - defaultMessage: 'Only numbers above 0 are allowed.', - } -); - -export const policyNameRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', - { - defaultMessage: 'A policy name is required.', - } -); - -export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', - { - defaultMessage: 'A policy name cannot start with an underscore.', - } -); -export const policyNameContainsCommaErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', - { - defaultMessage: 'A policy name cannot include a comma.', - } -); -export const policyNameContainsSpaceErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', - { - defaultMessage: 'A policy name cannot include a space.', - } -); - -export const policyNameTooLongErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', - { - defaultMessage: 'A policy name cannot be longer than 255 bytes.', - } -); -export const policyNameMustBeDifferentErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', - { - defaultMessage: 'The policy name must be different.', - } -); -export const policyNameAlreadyUsedErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', - { - defaultMessage: 'That policy name is already used.', - } -); -export type PhaseValidationErrors = { - [P in keyof Partial]: string[]; -}; - -export interface ValidationErrors { - delete: PhaseValidationErrors; - policyName: string[]; -} - -export const validatePolicy = ( - saveAsNew: boolean, - policy: LegacyPolicy, - policies: PolicyFromES[], - originalPolicyName: string -): [boolean, ValidationErrors] => { - const policyNameErrors: string[] = []; - if (!policy.name) { - policyNameErrors.push(policyNameRequiredMessage); - } else { - if (policy.name.startsWith('_')) { - policyNameErrors.push(policyNameStartsWithUnderscoreErrorMessage); - } - if (policy.name.includes(',')) { - policyNameErrors.push(policyNameContainsCommaErrorMessage); - } - if (policy.name.includes(' ')) { - policyNameErrors.push(policyNameContainsSpaceErrorMessage); - } - if (window.TextEncoder && new window.TextEncoder().encode(policy.name).length > 255) { - policyNameErrors.push(policyNameTooLongErrorMessage); - } - - if (saveAsNew && policy.name === originalPolicyName) { - policyNameErrors.push(policyNameMustBeDifferentErrorMessage); - } else if (policy.name !== originalPolicyName) { - const policyNames = policies.map((existingPolicy) => existingPolicy.name); - if (policyNames.includes(policy.name)) { - policyNameErrors.push(policyNameAlreadyUsedErrorMessage); - } - } - } - - const deletePhaseErrors = validateDeletePhase(policy.phases.delete); - const isValid = policyNameErrors.length === 0 && Object.keys(deletePhaseErrors).length === 0; - return [ - isValid, - { - policyName: [...policyNameErrors], - delete: deletePhaseErrors, - }, - ]; -}; - -export const findFirstError = (errors?: ValidationErrors): string | undefined => { - if (!errors) { - return; - } - - if (errors.policyName.length > 0) { - return propertyof('policyName'); - } - - if (Object.keys(errors.delete).length > 0) { - return `${propertyof('delete')}.${Object.keys(errors.delete)[0]}`; - } -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index 023aeba57aa7a..a127574d5bad0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -18,6 +18,7 @@ export { getFieldValidityAndErrorMessage, useFormContext, FormSchema, + ValidationConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; @@ -27,6 +28,8 @@ export { NumericField, SelectField, SuperSelectField, + ComboBoxField, + TextField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e7784846598e4..baa4f37791007 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9119,24 +9119,18 @@ "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesReloadButton": "再試行", "xpack.indexLifecycleMgmt.editPolicy.nodeDetailsLoadingFailedTitle": "ノード属性詳細を読み込めません", "xpack.indexLifecycleMgmt.editPolicy.nodeDetailsReloadButton": "再試行", - "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字が必要です。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "コールドフェーズのタイミング", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel": "コールドフェーズのタイミングの単位", "xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel": "削除フェーズのタイミング", "xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel": "削除フェーズのタイミングの単位", - "xpack.indexLifecycleMgmt.editPolicy.phaseErrorMessage": "エラーを修正してください", "xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel": "ウォームフェーズのタイミング", "xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel": "ウォームフェーズのタイミングの単位", "xpack.indexLifecycleMgmt.editPolicy.policiesLoading": "ポリシーを読み込み中…", "xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError": "このポリシー名は既に使用されています。", - "xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError": "ポリシー名にはコンマを使用できません。", - "xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError": "ポリシー名にはスペースを使用できません。", "xpack.indexLifecycleMgmt.editPolicy.policyNameLabel": "ポリシー名", "xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError": "ポリシー名が必要です。", "xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError": "ポリシー名の頭にアンダーラインを使用することはできません。", "xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError": "ポリシー名は 255 バイト未満である必要があります。", - "xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError": "0 よりも大きい数字のみ使用できます。", - "xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError": "プラスの数字のみ使用できます。", "xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel": "ロールオーバーからの経過日数", "xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel": "ロールオーバーからの経過時間数", "xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel": "ロールオーバーからの経過時間(マイクロ秒)", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f3cd662bacba7..c4274524928fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9128,24 +9128,18 @@ "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesReloadButton": "重试", "xpack.indexLifecycleMgmt.editPolicy.nodeDetailsLoadingFailedTitle": "无法加载节点属性详情", "xpack.indexLifecycleMgmt.editPolicy.nodeDetailsReloadButton": "重试", - "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字必填。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "冷阶段计时", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel": "冷阶段计时单位", "xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel": "删除阶段计时", "xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel": "删除阶段计时单位", - "xpack.indexLifecycleMgmt.editPolicy.phaseErrorMessage": "修复错误", "xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel": "温阶段计时", "xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel": "温阶段计时单位", "xpack.indexLifecycleMgmt.editPolicy.policiesLoading": "正在加载策略……", "xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError": "该策略名称已被使用。", - "xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError": "策略名称不能包含逗号。", - "xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError": "策略名称不能包含空格。", "xpack.indexLifecycleMgmt.editPolicy.policyNameLabel": "策略名称", "xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError": "策略名称必填。", "xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError": "策略名称不能以下划线开头。", "xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError": "策略名称的长度不能大于 255 字节。", - "xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError": "仅允许使用 0 以上的数字。", - "xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError": "仅允许使用正数。", "xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel": "天(自滚动更新)", "xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel": "小时(自滚动更新)", "xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel": "微秒(自滚动更新)", From c78cf35ba8bfdff5f61d3932cbacd45a69e4f773 Mon Sep 17 00:00:00 2001 From: Dhruv Bodani Date: Mon, 9 Nov 2020 19:05:05 +0530 Subject: [PATCH 9/9] Added `defaultActionMessage` to index threshold alert UI type definition (#80936) * resolves https://github.com/elastic/kibana/issues/78148 Adds a `defaultActionMessage` to the index threshold alert, so that the `message` parameter for actions will be pre-filled with a useful message --- .../index_threshold/action_context.test.ts | 3 ++ .../index_threshold/action_context.ts | 7 ++-- .../index_threshold/alert_type.test.ts | 4 ++ .../alert_types/index_threshold/alert_type.ts | 12 ++++++ .../builtin_alert_types/threshold/index.ts | 7 +++- .../index_threshold/alert.ts | 41 ++++++++++++++----- .../alert_create_flyout.ts | 5 ++- 7 files changed, 63 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts index 3f5addb77cb33..48847686828a9 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts @@ -25,6 +25,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'count > 4', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -53,6 +54,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'avg([aggField]) > 4.2', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -80,6 +82,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 4, + function: 'count between 4,5', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts index 5135e31e9322c..9bb0df9d07fd4 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts @@ -27,6 +27,8 @@ export interface BaseActionContext extends AlertInstanceContext { date: string; // the value that met the threshold value: number; + // the function that is used + function: string; } export function addMessages( @@ -42,9 +44,6 @@ export function addMessages( }, }); - const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; - const humanFn = `${agg} ${params.thresholdComparator} ${params.threshold.join(',')}`; - const window = `${params.timeWindowSize}${params.timeWindowUnit}`; const message = i18n.translate( 'xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', @@ -55,7 +54,7 @@ export function addMessages( name: alertInfo.name, group: baseContext.group, value: baseContext.value, - function: humanFn, + function: baseContext.function, window, date: baseContext.date, }, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts index 2f0cf3cbbcd16..d75f3af22ab06 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts @@ -46,6 +46,10 @@ describe('alertType', () => { "description": "The value that exceeded the threshold.", "name": "value", }, + Object { + "description": "A string describing the threshold comparator and threshold", + "name": "function", + }, ], "params": Array [ Object { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2a1ed429b7fe1..e0a9cd981dac0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -83,6 +83,13 @@ export function getAlertType(service: Service): AlertType { return { @@ -107,6 +114,7 @@ export function getAlertType(service: Service): AlertType import('./expression')), validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinAlertTypes.threshold.alertDefaultActionMessage', + { + defaultMessage: `alert \\{\\{alertName\\}\\} group \\{\\{context.group\\}\\} value \\{\\{context.value\\}\\} exceeded threshold \\{\\{context.function\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\} on \\{\\{context.date\\}\\}`, + } + ), requiresAppContext: false, }; } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index 92db0458c0639..c05fa6cf051ff 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -15,6 +15,7 @@ import { ObjectRemover, } from '../../../../../common/lib'; import { createEsDocuments } from './create_test_data'; +import { getAlertType } from '../../../../../../../plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/'; const ALERT_TYPE_ID = '.index-threshold'; const ACTION_TYPE_ID = '.index'; @@ -26,6 +27,8 @@ const ALERT_INTERVALS_TO_WRITE = 5; const ALERT_INTERVAL_SECONDS = 3; const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const DefaultActionMessage = getAlertType().defaultActionMessage; + // eslint-disable-next-line import/no-default-export export default function alertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -62,6 +65,10 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexToolOutput.destroy(); }); + it('has a default action message', () => { + expect(DefaultActionMessage).to.be.ok(); + }); + // The tests below create two alerts, one that will fire, one that will // never fire; the tests ensure the ones that should fire, do fire, and // those that shouldn't fire, do not fire. @@ -85,7 +92,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { const { group } = doc._source; - const { name, value, title, message } = doc._source.params; + const { name, title, message } = doc._source.params; expect(name).to.be('always fire'); expect(group).to.be('all documents'); @@ -93,9 +100,8 @@ export default function alertTests({ getService }: FtrProviderContext) { // we'll check title and message in this test, but not subsequent ones expect(title).to.be('alert always fire group all documents exceeded threshold'); - const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; - const messagePrefix = message.substr(0, expectedPrefix.length); - expect(messagePrefix).to.be(expectedPrefix); + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold count > -1 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -128,10 +134,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-\d value \d+ exceeded threshold count .+ over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -163,9 +172,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold sum\(testedValue\) between 0,1000000 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -195,9 +207,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(4); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value .+ exceeded threshold avg\(testedValue\) .+ 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -232,10 +247,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-2') inGroup2++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold max\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-2, rando split between others @@ -274,10 +292,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold min\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -329,7 +350,7 @@ export default function alertTests({ getService }: FtrProviderContext) { name: '{{{alertName}}}', value: '{{{context.value}}}', title: '{{{context.title}}}', - message: '{{{context.message}}}', + message: DefaultActionMessage, }, date: '{{{context.date}}}', // TODO: I wanted to write the alert value here, but how? diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index ee0de582a9bff..0f6da936f8644 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -79,10 +79,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); const createdConnectorToastTitle = await pageObjects.common.closeToast(); expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); + expect(await messageTextArea.getAttribute('value')).to.eql( + 'alert {{alertName}} group {{context.group}} value {{context.value}} exceeded threshold {{context.function}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} on {{context.date}}' + ); await testSubjects.setValue('messageTextArea', 'test message '); await testSubjects.click('messageAddVariableButton'); await testSubjects.click('variableMenuButton-0'); - const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); expect(await messageTextArea.getAttribute('value')).to.eql('test message {{alertId}}'); await messageTextArea.type(' some additional text ');