diff --git a/.editorconfig b/.editorconfig index 7564b3596f0433..ec8a51f2314bea 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,6 @@ insert_final_newline = true [package.json] insert_final_newline = false -[*.{md,asciidoc}] +[*.{md,mdx,asciidoc}] trim_trailing_whitespace = false insert_final_newline = false diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index a95b357570278d..655a491f8b3ca5 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,5 +62,6 @@ yarn kbn watch-bazel === List of Already Migrated Packages to Bazel - @elastic/datemath +- @kbn/apm-utils diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 860f7c3c748924..01079bdf03d0cd 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -88,6 +88,7 @@ readonly links: { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -114,9 +115,10 @@ readonly links: { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -127,6 +129,7 @@ readonly links: { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -143,6 +146,7 @@ readonly links: { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index a9cb6729b214e6..11814e7ca8b771 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
simulatePipeline: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index bbc9c41c6ca5a6..2944921edd2eea 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -81,8 +81,8 @@ by running checks on a schedule time of 1 minute with a re-notify interval of 6 [[kibana-alerts-large-shard-size]] == Large shard size -This alert is triggered if a large (primary) shard size is found on any of the -specified index patterns. The trigger condition is met if an index's shard size is +This alert is triggered if a large average shard size (across associated primaries) is found on any of the +specified index patterns. The trigger condition is met if an index's average shard size is 55gb or higher in the last 5 minutes. The alert is grouped across all indices that match the default pattern of `*` by running checks on a schedule time of 1 minute with a re-notify interval of 12 hours. diff --git a/package.json b/package.json index 9bddca46654674..c1f2a3b3cf1323 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", - "@kbn/apm-utils": "link:packages/kbn-apm-utils", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/crypto": "link:packages/kbn-crypto", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 31894fcb1bb5db..3944c2356badc7 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -3,6 +3,7 @@ filegroup( name = "build", srcs = [ - "//packages/elastic-datemath:build" + "//packages/elastic-datemath:build", + "//packages/kbn-apm-utils:build" ], ) diff --git a/packages/elastic-datemath/BUILD.bazel b/packages/elastic-datemath/BUILD.bazel index 6b9a725e91bd4d..bc0c1412ef5f15 100644 --- a/packages/elastic-datemath/BUILD.bazel +++ b/packages/elastic-datemath/BUILD.bazel @@ -4,15 +4,15 @@ load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") PKG_BASE_NAME = "elastic-datemath" PKG_REQUIRE_NAME = "@elastic/datemath" -SOURCE_FILES = [ +SOURCE_FILES = glob([ "src/index.ts", -] +]) SRCS = SOURCE_FILES filegroup( name = "srcs", - srcs = glob(SOURCE_FILES), + srcs = SRCS, ) NPM_MODULE_EXTRA_FILES = [ diff --git a/packages/elastic-datemath/tsconfig.json b/packages/elastic-datemath/tsconfig.json index d0fa806ed411b4..6e7219c7a82455 100644 --- a/packages/elastic-datemath/tsconfig.json +++ b/packages/elastic-datemath/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "declaration": true, "declarationMap": true, + "incremental": true, "outDir": "target", "rootDir": "src", "sourceMap": true, diff --git a/packages/kbn-apm-utils/BUILD.bazel b/packages/kbn-apm-utils/BUILD.bazel new file mode 100644 index 00000000000000..63adf2b77b5163 --- /dev/null +++ b/packages/kbn-apm-utils/BUILD.bazel @@ -0,0 +1,76 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-apm-utils" +PKG_REQUIRE_NAME = "@kbn/apm-utils" + +SOURCE_FILES = glob([ + "src/index.ts", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//elastic-apm-node", +] + +TYPES_DEPS = [ + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = [], + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-apm-utils/package.json b/packages/kbn-apm-utils/package.json index d414b94cb39789..04b8e2ed831b39 100644 --- a/packages/kbn-apm-utils/package.json +++ b/packages/kbn-apm-utils/package.json @@ -4,10 +4,5 @@ "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "private": true } diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json index e08769aab65436..3ce240059486a7 100644 --- a/packages/kbn-apm-utils/tsconfig.json +++ b/packages/kbn-apm-utils/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, - "outDir": "./target", - "stripInternal": false, "declaration": true, "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-apm-utils/src", "types": [ diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 249183d4b1e316..e114e3e9300168 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -100,7 +100,7 @@ pageLoadAssetSize: watcher: 43598 runtimeFields: 41752 stackAlerts: 29684 - presentationUtil: 28545 + presentationUtil: 49767 spacesOss: 18817 indexPatternFieldEditor: 90489 osquery: 107090 diff --git a/packages/kbn-test/src/jest/utils/testbed/testbed.ts b/packages/kbn-test/src/jest/utils/testbed/testbed.ts index edb040db8186c2..472b9f2df939cf 100644 --- a/packages/kbn-test/src/jest/utils/testbed/testbed.ts +++ b/packages/kbn-test/src/jest/utils/testbed/testbed.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { ComponentType, ReactWrapper } from 'enzyme'; +import { Component as ReactComponent } from 'react'; +import { ComponentType, HTMLAttributes, ReactWrapper } from 'enzyme'; import { findTestSubject } from '../find_test_subject'; import { reactRouterMock } from '../router_helpers'; @@ -250,8 +251,17 @@ export const registerTestBed = ( component.update(); }; - const getErrorsMessages: TestBed['form']['getErrorsMessages'] = () => { - const errorMessagesWrappers = component.find('.euiFormErrorText'); + const getErrorsMessages: TestBed['form']['getErrorsMessages'] = ( + wrapper?: T | ReactWrapper + ) => { + let errorMessagesWrappers: ReactWrapper; + if (typeof wrapper === 'string') { + errorMessagesWrappers = find(wrapper).find('.euiFormErrorText'); + } else { + errorMessagesWrappers = wrapper + ? wrapper.find('.euiFormErrorText') + : component.find('.euiFormErrorText'); + } return errorMessagesWrappers.map((err) => err.text()); }; diff --git a/packages/kbn-test/src/jest/utils/testbed/types.ts b/packages/kbn-test/src/jest/utils/testbed/types.ts index 338794869d9b18..520a78d03d7013 100644 --- a/packages/kbn-test/src/jest/utils/testbed/types.ts +++ b/packages/kbn-test/src/jest/utils/testbed/types.ts @@ -133,7 +133,7 @@ export interface TestBed { /** * Get a list of the form error messages that are visible in the DOM. */ - getErrorsMessages: () => string[]; + getErrorsMessages: (wrapper?: T | ReactWrapper) => string[]; }; table: { getMetaData: (tableTestSubject: T) => EuiTableMetaData; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index baf8ed2a61645b..1bff91f15a150e 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -109,6 +109,7 @@ export class DocLinksService { top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, }, runtimeFields: { + overview: `${ELASTICSEARCH_DOCS}runtime.html`, mapping: `${ELASTICSEARCH_DOCS}runtime-mapping-fields.html`, }, scriptedFields: { @@ -130,8 +131,49 @@ export class DocLinksService { addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, elasticsearch: { + docsBase: `${ELASTICSEARCH_DOCS}`, + asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, + dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`, indexModules: `${ELASTICSEARCH_DOCS}index-modules.html`, + indexSettings: `${ELASTICSEARCH_DOCS}index-modules.html#index-modules-settings`, + indexTemplates: `${ELASTICSEARCH_DOCS}indices-templates.html`, mapping: `${ELASTICSEARCH_DOCS}mapping.html`, + mappingAnalyzer: `${ELASTICSEARCH_DOCS}analyzer.html`, + mappingCoerce: `${ELASTICSEARCH_DOCS}coerce.html`, + mappingCopyTo: `${ELASTICSEARCH_DOCS}copy-to.html`, + mappingDocValues: `${ELASTICSEARCH_DOCS}doc-values.html`, + mappingDynamic: `${ELASTICSEARCH_DOCS}dynamic.html`, + mappingDynamicFields: `${ELASTICSEARCH_DOCS}dynamic-field-mapping.html`, + mappingDynamicTemplates: `${ELASTICSEARCH_DOCS}dynamic-templates.html`, + mappingEagerGlobalOrdinals: `${ELASTICSEARCH_DOCS}eager-global-ordinals.html`, + mappingEnabled: `${ELASTICSEARCH_DOCS}enabled.html`, + mappingFieldData: `${ELASTICSEARCH_DOCS}text.html#fielddata-mapping-param`, + mappingFieldDataEnable: `${ELASTICSEARCH_DOCS}text.html#before-enabling-fielddata`, + mappingFieldDataFilter: `${ELASTICSEARCH_DOCS}text.html#field-data-filtering`, + mappingFieldDataTypes: `${ELASTICSEARCH_DOCS}mapping-types.html`, + mappingFormat: `${ELASTICSEARCH_DOCS}mapping-date-format.html`, + mappingIgnoreAbove: `${ELASTICSEARCH_DOCS}ignore-above.html`, + mappingIgnoreMalformed: `${ELASTICSEARCH_DOCS}ignore-malformed.html`, + mappingIndex: `${ELASTICSEARCH_DOCS}mapping-index.html`, + mappingIndexOptions: `${ELASTICSEARCH_DOCS}index-options.html`, + mappingIndexPhrases: `${ELASTICSEARCH_DOCS}index-phrases.html`, + mappingIndexPrefixes: `${ELASTICSEARCH_DOCS}index-prefixes.html`, + mappingJoinFieldsPerformance: `${ELASTICSEARCH_DOCS}parent-join.html#_parent_join_and_performance`, + mappingMeta: `${ELASTICSEARCH_DOCS}mapping-field-meta.html`, + mappingMetaFields: `${ELASTICSEARCH_DOCS}mapping-meta-field.html`, + mappingNormalizer: `${ELASTICSEARCH_DOCS}normalizer.html`, + mappingNorms: `${ELASTICSEARCH_DOCS}norms.html`, + mappingNullValue: `${ELASTICSEARCH_DOCS}null-value.html`, + mappingParameters: `${ELASTICSEARCH_DOCS}mapping-params.html`, + mappingPositionIncrementGap: `${ELASTICSEARCH_DOCS}position-increment-gap.html`, + mappingRankFeatureFields: `${ELASTICSEARCH_DOCS}rank-feature.html`, + mappingRouting: `${ELASTICSEARCH_DOCS}mapping-routing-field.html`, + mappingSimilarity: `${ELASTICSEARCH_DOCS}similarity.html`, + mappingSourceFields: `${ELASTICSEARCH_DOCS}mapping-source-field.html`, + mappingSourceFieldsDisable: `${ELASTICSEARCH_DOCS}mapping-source-field.html#disable-source-field`, + mappingStore: `${ELASTICSEARCH_DOCS}mapping-store.html`, + mappingTermVector: `${ELASTICSEARCH_DOCS}term-vector.html`, + mappingTypesRemoval: `${ELASTICSEARCH_DOCS}removal-of-types.html`, nodeRoles: `${ELASTICSEARCH_DOCS}modules-node.html#node-roles`, remoteClusters: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html`, remoteClustersProxy: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#proxy-mode`, @@ -146,17 +188,19 @@ export class DocLinksService { }, query: { eql: `${ELASTICSEARCH_DOCS}eql.html`, + kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`, luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`, + percolate: `${ELASTICSEARCH_DOCS}query-dsl-percolate-query.html`, queryDsl: `${ELASTICSEARCH_DOCS}query-dsl.html`, - kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`, }, date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, dateMathIndexNames: `${ELASTICSEARCH_DOCS}date-math-index-names.html`, }, management: { - kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, dashboardSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-dashboard-settings`, + indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, + kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, visualizationSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-visualization-settings`, }, ml: { @@ -258,6 +302,7 @@ export class DocLinksService { skippingDisconnectedClusters: `${ELASTICSEARCH_DOCS}modules-cross-cluster-search.html#skip-unavailable-clusters`, }, apis: { + bulkIndexAlias: `${ELASTICSEARCH_DOCS}indices-aliases.html`, createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`, createSnapshotLifecyclePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, createRoleMapping: `${ELASTICSEARCH_DOCS}security-api-put-role-mapping.html`, @@ -274,6 +319,7 @@ export class DocLinksService { painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, putComponentTemplateMetadata: `${ELASTICSEARCH_DOCS}indices-component-template.html#component-templates-metadata`, putEnrichPolicy: `${ELASTICSEARCH_DOCS}put-enrich-policy-api.html`, + putIndexTemplateV1: `${ELASTICSEARCH_DOCS}indices-templates-v1.html`, putSnapshotLifecyclePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, putWatch: `${ELASTICSEARCH_DOCS}watcher-api-put-watch.html`, simulatePipeline: `${ELASTICSEARCH_DOCS}simulate-pipeline-api.html`, @@ -429,6 +475,7 @@ export interface DocLinksStart { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -455,9 +502,10 @@ export interface DocLinksStart { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -468,6 +516,7 @@ export interface DocLinksStart { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -484,6 +533,7 @@ export interface DocLinksStart { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8327428991e13b..3f4de7fccac72b 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -571,6 +571,7 @@ export interface DocLinksStart { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -597,9 +598,10 @@ export interface DocLinksStart { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -610,6 +612,7 @@ export interface DocLinksStart { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -626,6 +629,7 @@ export interface DocLinksStart { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/src/plugins/dashboard/.storybook/storyshots.test.tsx b/src/plugins/dashboard/.storybook/storyshots.test.tsx deleted file mode 100644 index 80e8aa795ed400..00000000000000 --- a/src/plugins/dashboard/.storybook/storyshots.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import fs from 'fs'; -import { ReactChildren } from 'react'; -import path from 'path'; -import moment from 'moment'; -import 'moment-timezone'; -import ReactDOM from 'react-dom'; - -import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots'; -// @ts-ignore -import styleSheetSerializer from 'jest-styled-components/src/styleSheetSerializer'; -import { addSerializer } from 'jest-specific-snapshot'; - -// Set our default timezone to UTC for tests so we can generate predictable snapshots -moment.tz.setDefault('UTC'); - -// Freeze time for the tests for predictable snapshots -const testTime = new Date(Date.UTC(2019, 5, 1)); // June 1 2019 -Date.now = jest.fn(() => testTime.getTime()); - -// Mock React Portal for components that use modals, tooltips, etc -// @ts-expect-error Portal mocks are notoriously difficult to type -ReactDOM.createPortal = jest.fn((element) => element); - -// Mock EUI generated ids to be consistently predictable for snapshots. -jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); - -// Mock react-datepicker dep used by eui to avoid rendering the entire large component -jest.mock('@elastic/eui/packages/react-datepicker', () => { - return { - __esModule: true, - default: 'ReactDatePicker', - }; -}); - -// Mock the EUI HTML ID Generator so elements have a predictable ID in snapshots -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - -// To be resolved by EUI team. -// https://github.com/elastic/eui/issues/3712 -jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { - return { - EuiOverlayMask: ({ children }: { children: ReactChildren }) => children, - }; -}); - -// @ts-ignore -import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; -jest.mock('@elastic/eui/test-env/components/observer/observer'); -EuiObserver.mockImplementation(() => 'EuiObserver'); - -// Some of the code requires that this directory exists, but the tests don't actually require any css to be present -const cssDir = path.resolve(__dirname, '../../../../built_assets/css'); -if (!fs.existsSync(cssDir)) { - fs.mkdirSync(cssDir, { recursive: true }); -} - -addSerializer(styleSheetSerializer); - -// Initialize Storyshots and build the Jest Snapshots -initStoryshots({ - configPath: path.resolve(__dirname, './../.storybook'), - framework: 'react', - test: multiSnapshotWithOptions({}), -}); diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 32507cbc5e5f47..41335069461fae 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -23,6 +23,7 @@ "requiredBundles": [ "home", "kibanaReact", - "kibanaUtils" + "kibanaUtils", + "presentationUtil" ] } diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index a82aa78b815eca..ef0cd376df98b0 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -11,6 +11,12 @@ import angular from 'angular'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import UseUnmount from 'react-use/lib/useUnmount'; +import { + AddFromLibraryButton, + PrimaryActionButton, + QuickButtonGroup, + SolutionToolbar, +} from '../../../../presentation_util/public'; import { useKibana } from '../../services/kibana_react'; import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data'; import { @@ -43,9 +49,9 @@ import { showCloneModal } from './show_clone_modal'; import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; -import { PanelToolbar } from './panel_toolbar'; import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; +import { DashboardConstants } from '../../dashboard_constants'; import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; import { DashboardContainer } from '..'; @@ -103,6 +109,8 @@ export function DashboardTopNav({ const [state, setState] = useState({ chromeIsVisible: false }); const [isSaveInProgress, setIsSaveInProgress] = useState(false); + const stateTransferService = embeddable.getStateTransfer(); + useEffect(() => { const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { setState((s) => ({ ...s, chromeIsVisible })); @@ -147,12 +155,26 @@ export function DashboardTopNav({ const createNew = useCallback(async () => { const type = 'visualization'; const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { throw new EmbeddableFactoryNotFoundError(type); } + await factory.create({} as EmbeddableInput, dashboardContainer); }, [dashboardContainer, embeddable]); + const createNewVisType = useCallback( + (newVisType: string) => async () => { + stateTransferService.navigateToEditor('visualize', { + path: `#/create?type=${encodeURIComponent(newVisType)}`, + state: { + originatingApp: DashboardConstants.DASHBOARDS_ID, + }, + }); + }, + [stateTransferService] + ); + const clearAddPanel = useCallback(() => { if (state.addPanelOverlay) { state.addPanelOverlay.close(); @@ -540,11 +562,51 @@ export function DashboardTopNav({ }; const { TopNavMenu } = navigation.ui; + + const quickButtons = [ + { + iconType: 'visText', + createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', { + defaultMessage: 'Markdown', + }), + onClick: createNewVisType('markdown'), + 'data-test-subj': 'dashboardMarkdownQuickButton', + }, + { + iconType: 'controlsHorizontal', + createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', { + defaultMessage: 'Input control', + }), + onClick: createNewVisType('input_control_vis'), + 'data-test-subj': 'dashboardInputControlsQuickButton', + }, + ]; + return ( <> {viewMode !== ViewMode.VIEW ? ( - + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + addFromLibraryButton: ( + + ), + }} + ) : null} ); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot deleted file mode 100644 index afbbecb3935e0e..00000000000000 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ /dev/null @@ -1,71 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/PanelToolbar default 1`] = ` -
-
- -
-
- -
-
-`; diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx deleted file mode 100644 index 0449fae80186d0..00000000000000 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './panel_toolbar.scss'; -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -interface Props { - /** The click handler for the Add Panel button for creating new panels */ - onAddPanelClick: () => void; - /** The click handler for the Library button for adding existing visualizations/embeddables */ - onLibraryClick: () => void; -} - -export const PanelToolbar: FC = ({ onAddPanelClick, onLibraryClick }) => ( - - - - {i18n.translate('dashboard.panelToolbar.addPanelButtonLabel', { - defaultMessage: 'Create panel', - })} - - - - - {i18n.translate('dashboard.panelToolbar.libraryButtonLabel', { - defaultMessage: 'Add from library', - })} - - - -); diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index dd99119cfb4570..12820fc08310d6 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -32,5 +32,8 @@ { "path": "../saved_objects/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../spaces_oss/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../discover/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, ] } diff --git a/src/plugins/data/server/index_patterns/index_patterns_api_client.ts b/src/plugins/data/server/index_patterns/index_patterns_api_client.ts index 941a90f500ab66..0ed84d4eee3b7a 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_api_client.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_api_client.ts @@ -12,6 +12,7 @@ import { IIndexPatternsApiClient, GetFieldsOptionsTimePattern, } from '../../common/index_patterns/types'; +import { IndexPatternMissingIndices } from '../../common/index_patterns/lib'; import { IndexPatternsFetcher } from './fetcher'; export class IndexPatternsApiServer implements IIndexPatternsApiClient { @@ -27,12 +28,23 @@ export class IndexPatternsApiServer implements IIndexPatternsApiClient { allowNoIndex, }: GetFieldsOptions) { const indexPatterns = new IndexPatternsFetcher(this.esClient, allowNoIndex); - return await indexPatterns.getFieldsForWildcard({ - pattern, - metaFields, - type, - rollupIndex, - }); + return await indexPatterns + .getFieldsForWildcard({ + pattern, + metaFields, + type, + rollupIndex, + }) + .catch((err) => { + if ( + err.output.payload.statusCode === 404 && + err.output.payload.code === 'no_matching_indices' + ) { + throw new IndexPatternMissingIndices(pattern); + } else { + throw err; + } + }); } async getFieldsForTimePattern(options: GetFieldsOptionsTimePattern) { const indexPatterns = new IndexPatternsFetcher(this.esClient); diff --git a/src/plugins/data/server/index_patterns/index_patterns_service.ts b/src/plugins/data/server/index_patterns/index_patterns_service.ts index c4cc2073ef78f0..c7fd1f7914df9f 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -71,7 +71,7 @@ export const indexPatternsServiceFactory = ({ logger.error(error); }, onNotification: ({ title, text }) => { - logger.warn(`${title} : ${text}`); + logger.warn(`${title}${text ? ` : ${text}` : ''}`); }, }); }; diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx new file mode 100644 index 00000000000000..a2434170acdd7d --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { DocViewerTab } from './doc_viewer_tab'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; + +describe('DocViewerTab', () => { + test('changing columns triggers an update', () => { + const props = { + title: 'test', + component: jest.fn(), + id: 1, + render: jest.fn(), + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test'], + }, + }; + + const wrapper = shallow(); + + const nextProps = { + ...props, + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test2'], + }, + }; + + const shouldUpdate = (wrapper!.instance() as DocViewerTab).shouldComponentUpdate(nextProps, { + hasError: false, + error: '', + }); + expect(shouldUpdate).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx index 25454a3bad38ab..1ad6500771d483 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n/react'; import { DocViewRenderTab } from './doc_viewer_render_tab'; import { DocViewerError } from './doc_viewer_render_error'; @@ -46,6 +47,7 @@ export class DocViewerTab extends React.Component { return ( nextProps.renderProps.hit._id !== this.props.renderProps.hit._id || nextProps.id !== this.props.id || + !isEqual(nextProps.renderProps.columns, this.props.renderProps.columns) || nextState.hasError ); } diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx index 7d79200bc6f877..b3fada3dbd00ff 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx @@ -268,7 +268,7 @@ describe('', () => { expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']); // We change the type and expect the form error to not be there anymore - await changeFieldType('long'); + await changeFieldType('keyword'); expect(form.getErrorsMessages()).toEqual([]); }); }); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 3785096e206273..fc25879b128ec0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -15,6 +15,7 @@ import { EuiSpacer, EuiComboBoxOptionOption, EuiCode, + EuiCallOut, } from '@elastic/eui'; import type { CoreStart } from 'src/core/public'; @@ -138,6 +139,11 @@ const geti18nTexts = (): { }, }); +const changeWarning = i18n.translate('indexPatternFieldEditor.editor.form.changeWarning', { + defaultMessage: + 'Changing name or type can break searches and visualizations that rely on this field.', +}); + const formDeserializer = (field: Field): FieldFormInternal => { let fieldType: Array>; if (!field.type) { @@ -204,6 +210,11 @@ const FieldEditorComponent = ({ clearSyntaxError(); }, [type, clearSyntaxError]); + const [{ name: updatedName, type: updatedType }] = useFormData({ form }); + const nameHasChanged = Boolean(field?.name) && field?.name !== updatedName; + const typeHasChanged = + Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); + return (
@@ -231,6 +242,18 @@ const FieldEditorComponent = ({ + {(nameHasChanged || typeHasChanged) && ( + <> + + + + )} {/* Set custom label */} diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts similarity index 81% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts rename to src/plugins/presentation_util/public/components/solution_toolbar/index.ts index fd0ce66beb97c7..332d60787b8cbb 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts +++ b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { PanelToolbar } from './panel_toolbar'; +export { SolutionToolbar } from './solution_toolbar'; +export * from './items'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx new file mode 100644 index 00000000000000..0550de1d069fa0 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ComponentStrings } from '../../../i18n/components'; +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; + +const { SolutionToolbar: strings } = ComponentStrings; + +export type Props = Omit; + +export const AddFromLibraryButton = ({ onClick, ...rest }: Props) => ( + +); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss similarity index 73% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss rename to src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index 9ad6a5257df3ea..79c3d4cca7ace1 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -1,9 +1,5 @@ -.panelToolbar { - padding: 0 $euiSizeS $euiSizeS; - flex-grow: 0; -} -.panelToolbarButton { +.solutionToolbarButton { line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx new file mode 100644 index 00000000000000..5de8e24ef5f0de --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +import './button.scss'; + +export interface Props extends Pick { + label: string; + primary?: boolean; +} + +export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => ( + + {label} + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts new file mode 100644 index 00000000000000..654831e86d3f68 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { SolutionToolbarButton } from './button'; +export { SolutionToolbarPopover } from './popover'; +export { AddFromLibraryButton } from './add_from_library'; +export { QuickButtonProps, QuickButtonGroup } from './quick_group'; +export { PrimaryActionButton } from './primary_button'; +export { PrimaryActionPopover } from './primary_popover'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx new file mode 100644 index 00000000000000..fbb34e165190d5 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { Props as EuiPopoverProps } from '@elastic/eui/src/components/popover/popover'; + +import { SolutionToolbarButton, Props as ButtonProps } from './button'; + +type AllowedButtonProps = Omit; +type AllowedPopoverProps = Omit< + EuiPopoverProps, + 'button' | 'isOpen' | 'closePopover' | 'anchorPosition' +>; + +export type Props = AllowedButtonProps & AllowedPopoverProps; + +export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const onButtonClick = () => setIsOpen((status) => !status); + const closePopover = () => setIsOpen(false); + + const button = ( + + ); + + return ( + + ); +}; diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx similarity index 53% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx rename to src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx index 5525b1cd069ad4..e2ef75e45a4049 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx @@ -6,14 +6,10 @@ * Side Public License, v 1. */ -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; import React from 'react'; -import { PanelToolbar } from './panel_toolbar'; -storiesOf('components/PanelToolbar', module).add('default', () => ( - -)); +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; + +export const PrimaryActionButton = (props: Omit) => ( + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx new file mode 100644 index 00000000000000..164d4c9b4a1a62 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { SolutionToolbarPopover, Props as SolutionToolbarPopoverProps } from './popover'; + +export type Props = Omit; + +export const PrimaryActionPopover = (props: Omit) => ( + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss new file mode 100644 index 00000000000000..639ff5bf2a117a --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -0,0 +1,5 @@ +.quickButtonGroup { + .quickButtonGroup__button { + background-color: $euiColorEmptyShade; + } +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx new file mode 100644 index 00000000000000..58f8bd803b636a --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButtonGroup, htmlIdGenerator, EuiButtonGroupOptionProps } from '@elastic/eui'; +import { ComponentStrings } from '../../../i18n/components'; + +const { QuickButtonGroup: strings } = ComponentStrings; + +import './quick_group.scss'; + +export interface QuickButtonProps extends Pick { + createType: string; + onClick: () => void; +} + +export interface Props { + buttons: QuickButtonProps[]; +} + +type Option = EuiButtonGroupOptionProps & Omit; + +export const QuickButtonGroup = ({ buttons }: Props) => { + const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { + const { createType: label, ...rest } = button; + const title = strings.getAriaButtonLabel(label); + + return { + ...rest, + 'aria-label': title, + className: 'quickButtonGroup__button', + id: `${htmlIdGenerator()()}${index}`, + label, + title, + }; + }); + + const onChangeIconsMulti = (optionId: string) => { + buttonGroupOptions.find((x) => x.id === optionId)?.onClick(); + }; + + return ( + + ); +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss new file mode 100644 index 00000000000000..18160acf191e44 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss @@ -0,0 +1,4 @@ +.solutionToolbar { + padding: 0 $euiSizeS $euiSizeS; + flex-grow: 0; +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx new file mode 100644 index 00000000000000..fa33f53f9ae4f8 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Story } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiContextMenu } from '@elastic/eui'; + +import { SolutionToolbar } from './solution_toolbar'; +import { SolutionToolbarPopover } from './items'; +import { AddFromLibraryButton, PrimaryActionButton, QuickButtonGroup } from './items'; + +const quickButtons = [ + { + createType: 'Text', + onClick: action('onTextClick'), + iconType: 'visText', + }, + { + createType: 'Control', + onClick: action('onControlClick'), + iconType: 'controlsHorizontal', + }, + { + createType: 'Link', + onClick: action('onLinkClick'), + iconType: 'link', + }, + { + createType: 'Image', + onClick: action('onImageClick'), + iconType: 'image', + }, + { + createType: 'Markup', + onClick: action('onMarkupClick'), + iconType: 'visVega', + }, +]; + +const primaryButtonConfigs = { + Generic: ( + + ), + Canvas: ( + + + + ), + Dashboard: ( + + ), +}; + +const extraButtonConfigs = { + Generic: undefined, + Canvas: undefined, + Dashboard: [ + + + , + ], +}; + +export default { + title: 'Solution Toolbar', + description: 'A universal toolbar for solutions maintained by the Presentation Team.', + component: SolutionToolbar, + argTypes: { + quickButtonCount: { + defaultValue: 2, + control: { + type: 'number', + min: 0, + max: 5, + step: 1, + }, + }, + showAddFromLibraryButton: { + defaultValue: true, + control: { + type: 'boolean', + }, + }, + solution: { + table: { + disable: true, + }, + }, + }, + // https://github.com/storybookjs/storybook/issues/11543#issuecomment-684130442 + parameters: { + docs: { + source: { + type: 'code', + }, + }, + }, +}; + +const Template: Story<{ + solution: 'Generic' | 'Canvas' | 'Dashboard'; + quickButtonCount: number; + showAddFromLibraryButton: boolean; +}> = ({ quickButtonCount, solution, showAddFromLibraryButton }) => { + const primaryActionButton = primaryButtonConfigs[solution]; + const extraButtons = extraButtonConfigs[solution]; + let quickButtonGroup; + let addFromLibraryButton; + + if (quickButtonCount > 0) { + quickButtonGroup = ; + } + + if (showAddFromLibraryButton) { + addFromLibraryButton = ; + } + + return ( + + {{ + primaryActionButton, + quickButtonGroup, + extraButtons, + addFromLibraryButton, + }} + + ); +}; + +export const Generic = Template.bind({}); +Generic.args = { + ...Template.args, + solution: 'Generic', +}; + +export const Canvas = Template.bind({}); +Canvas.args = { + ...Template.args, + solution: 'Canvas', +}; + +export const Dashboard = Template.bind({}); +Dashboard.args = { + ...Template.args, + solution: 'Dashboard', +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx new file mode 100644 index 00000000000000..bb8b04e8b8f09c --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { + AddFromLibraryButton, + QuickButtonGroup, + PrimaryActionButton, + SolutionToolbarButton, + PrimaryActionPopover, + SolutionToolbarPopover, +} from './items'; + +import './solution_toolbar.scss'; + +interface NamedSlots { + primaryActionButton: ReactElement; + quickButtonGroup?: ReactElement; + addFromLibraryButton?: ReactElement; + extraButtons?: Array>; +} + +export interface Props { + children: NamedSlots; +} + +export const SolutionToolbar = ({ children }: Props) => { + const { + primaryActionButton, + quickButtonGroup, + addFromLibraryButton, + extraButtons = [], + } = children; + + const extra = extraButtons.map((button, index) => + button ? ( + + {button} + + ) : null + ); + + return ( + + {primaryActionButton} + {quickButtonGroup ? {quickButtonGroup} : null} + {extra} + {addFromLibraryButton ? {addFromLibraryButton} : null} + + ); +}; diff --git a/src/plugins/presentation_util/public/i18n/components.ts b/src/plugins/presentation_util/public/i18n/components.ts new file mode 100644 index 00000000000000..ab0e6d1bdbda0c --- /dev/null +++ b/src/plugins/presentation_util/public/i18n/components.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ComponentStrings = { + SolutionToolbar: { + getEditorMenuButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.editorMenuButtonLabel', { + defaultMessage: 'All editors', + }), + getLibraryButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.libraryButtonLabel', { + defaultMessage: 'Add from library', + }), + }, + QuickButtonGroup: { + getAriaButtonLabel: (createType: string) => + i18n.translate('presentationUtil.solutionToolbar.quickButton.ariaButtonLabel', { + defaultMessage: `Create new {createType}`, + values: { + createType, + }, + }), + getLegend: () => + i18n.translate('presentationUtil.solutionToolbar.quickButton.legendLabel', { + defaultMessage: 'Quick create', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 1cbf4b5a4f3347..9c5f65de409555 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -19,6 +19,16 @@ export { LazySavedObjectSaveModalDashboard, withSuspense, } from './components'; +export { + AddFromLibraryButton, + PrimaryActionButton, + PrimaryActionPopover, + QuickButtonGroup, + QuickButtonProps, + SolutionToolbar, + SolutionToolbarButton, + SolutionToolbarPopover, +} from './components/solution_toolbar'; export function plugin() { return new PresentationUtilPlugin(); diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 63d136cf9445a6..c0fafe8c3aabad 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -15,11 +15,8 @@ "../../../typings/**/*" ], "references": [ - { - "path": "../../core/tsconfig.json" - }, - { - "path": "../saved_objects/tsconfig.json" - }, + { "path": "../../core/tsconfig.json" }, + { "path": "../saved_objects/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, ] } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index c892f27905e0d6..d2113dce9548ff 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -46,6 +46,15 @@ describe('get_data_telemetry', () => { ).toStrictEqual([]); }); + test('should not include Async Search indices', () => { + expect( + buildDataTelemetryPayload([ + { name: '.async_search', docCount: 0 }, + { name: '.async-search', docCount: 0 }, + ]) + ).toStrictEqual([]); + }); + test('matches some indices and puts them in their own category', () => { expect( buildDataTelemetryPayload([ diff --git a/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts new file mode 100644 index 00000000000000..9e41df38804195 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const mock = jest.requireActual('../index_patterns_utils'); + +jest.spyOn(mock, 'fetchIndexPattern'); + +export const { + isStringTypeIndexPattern, + getIndexPatternKey, + extractIndexPatternValues, + fetchIndexPattern, +} = mock; diff --git a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx index 7fba2e1cb701fc..13d06e1c9a18de 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx @@ -59,22 +59,44 @@ function TimeseriesVisualization({ const indexPatternValue = model.index_pattern || ''; const { indexPatterns } = getDataStart(); const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); + let event; + // trigger applyFilter if no index pattern found, url drilldowns are supported only + // for the index pattern mode + if (indexPattern) { + const tables = indexPattern + ? await convertSeriesToDataTable(model, series, indexPattern) + : null; + const table = tables?.[model.series[0].id]; + + const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; + event = { + data: { + table, + column: X_ACCESSOR_INDEX, + range, + timeFieldName: indexPattern?.timeFieldName, + }, + name: 'brush', + }; + } else { + event = { + name: 'applyFilter', + data: { + timeFieldName: '*', + filters: [ + { + range: { + '*': { + gte, + lte, + }, + }, + }, + ], + }, + }; + } - const tables = indexPattern - ? await convertSeriesToDataTable(model, series, indexPattern) - : null; - const table = tables?.[model.series[0].id]; - - const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; - const event = { - data: { - table, - column: X_ACCESSOR_INDEX, - range, - timeFieldName: indexPattern?.timeFieldName, - }, - name: 'brush', - }; handlers.event(event); }, [handlers, model] diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts index 3e6f8c2962d5a0..813b0a22c0c371 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -7,11 +7,14 @@ */ import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getCachedIndexPatternFetcher, CachedIndexPatternFetcher, } from './cached_index_pattern_fetcher'; +jest.mock('../../../../common/index_patterns_utils'); + describe('CachedIndexPatternFetcher', () => { let mockedIndices: IndexPattern[] | []; let cachedIndexPatternFetcher: CachedIndexPatternFetcher; @@ -25,6 +28,8 @@ describe('CachedIndexPatternFetcher', () => { find: jest.fn(() => Promise.resolve(mockedIndices || [])), } as unknown) as IndexPatternsService; + (fetchIndexPattern as jest.Mock).mockClear(); + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); }); @@ -52,6 +57,14 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); describe('object-based index', () => { @@ -86,5 +99,20 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index 68cbd93cdc614d..b03fa973e9da99 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -23,7 +23,7 @@ export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatterns const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); - cache.set(indexPatternValue, fetchedIndex); + cache.set(key, fetchedIndex); return fetchedIndex; }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index c2b9fcd77757a4..2b5a611cd946e8 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -58,7 +58,7 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick >; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 8f1ebe25b50599..901593626a9451 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -17,7 +17,6 @@ import { dataPluginMock } from '../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; -import { dashboardPluginMock } from '../../../plugins/dashboard/public/mocks'; import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ @@ -62,7 +61,6 @@ const createInstance = async () => { uiActions: uiActionsPluginMock.createStartContract(), application: applicationServiceMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), - dashboard: dashboardPluginMock.createStartContract(), getAttributeService: jest.fn(), savedObjectsClient: coreMock.createStart().savedObjects.client, savedObjects: savedObjectsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index d4e7132a1a21ea..081f5d65103c20 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -62,7 +62,6 @@ import { convertToSerializedVis, } from './saved_visualizations/_saved_vis'; import { createSavedSearchesLoader } from '../../discover/public'; -import { DashboardStart } from '../../dashboard/public'; import { SavedObjectsStart } from '../../saved_objects/public'; /** @@ -97,7 +96,6 @@ export interface VisualizationsStartDeps { inspector: InspectorStart; uiActions: UiActionsStart; application: ApplicationStart; - dashboard: DashboardStart; getAttributeService: EmbeddableStart['getAttributeService']; savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; @@ -145,7 +143,7 @@ export class VisualizationsPlugin public start( core: CoreStart, - { data, expressions, uiActions, embeddable, dashboard, savedObjects }: VisualizationsStartDeps + { data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); setTypes(types); diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index d7c5e6a4b43663..356448aa59771e 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -15,7 +15,6 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, - { "path": "../dashboard/tsconfig.json" }, { "path": "../expressions/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts index 33a840fd093fc7..c75b6c607f56e3 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts @@ -430,7 +430,8 @@ export default function ({ getService }: FtrProviderContext) { }); it('can set field "format" on an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = indexPattern.title; + await supertest.delete(`/api/index_patterns/index_pattern/${indexPattern.id}`); const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts index 75450b034f2fda..f9ab482f98b764 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts @@ -11,8 +11,17 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can create a new scripted field', async () => { const title = `foo-${Date.now()}-${Math.random()}*`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ @@ -40,7 +49,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('newly created scripted field is materialized in the index_pattern object', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -51,7 +60,7 @@ export default function ({ getService }: FtrProviderContext) { .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field`) .send({ field: { - name: 'bar', + name: 'bar2', type: 'number', scripted: true, script: "doc['field_name'].value", @@ -64,12 +73,15 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.status).to.be(200); - const field = response2.body.index_pattern.fields.bar; + const field = response2.body.index_pattern.fields.bar2; - expect(field.name).to.be('bar'); + expect(field.name).to.be('bar2'); expect(field.type).to.be('number'); expect(field.scripted).to.be(true); expect(field.script).to.be("doc['field_name'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts index 030679a4dd48a6..40f57cd914a2f4 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts @@ -11,16 +11,25 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can remove a scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, fields: { bar: { - name: 'bar', + name: 'bar2', type: 'number', scripted: true, script: "doc['field_name'].value", @@ -33,10 +42,10 @@ export default function ({ getService }: FtrProviderContext) { '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id ); - expect(typeof response2.body.index_pattern.fields.bar).to.be('object'); + expect(typeof response2.body.index_pattern.fields.bar2).to.be('object'); const response3 = await supertest.delete( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field/bar` + `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field/bar2` ); expect(response3.status).to.be(200); @@ -45,7 +54,10 @@ export default function ({ getService }: FtrProviderContext) { '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id ); - expect(typeof response4.body.index_pattern.fields.bar).to.be('undefined'); + expect(typeof response4.body.index_pattern.fields.bar2).to.be('undefined'); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts index c23f41f8b31ddd..7fff720e5195f3 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can fetch a scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -47,6 +56,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.body.field.type).to.be('number'); expect(response2.body.field.scripted).to.be(true); expect(response2.body.field.script).to.be("doc['field_name'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts index 3029a351fdae1d..dec20961b0de09 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can overwrite an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -63,10 +72,13 @@ export default function ({ getService }: FtrProviderContext) { expect(response3.status).to.be(200); expect(response3.body.field.type).to.be('string'); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); it('can add a new scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -100,6 +112,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.status).to.be(200); expect(response2.body.field.script).to.be("doc['bar'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts index 943601d1b2a762..ac6b11522124ba 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can update an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -56,6 +65,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response3.status).to.be(200); expect(response3.body.field.type).to.be('string'); expect(response3.body.field.script).to.be("doc['bar'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index f4ee4e99047686..9b8fc4785a6718 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -69,6 +69,36 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); }); + it('adds a markdown visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.clickMarkdownQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from markdown quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds an input control visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.clickInputControlsQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from input control quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + it('saves the listing page instead of the visualization to the app link', async () => { await PageObjects.header.clickVisualize(true); const currentUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index d5df97881a1d3b..ce32f53587e747 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -15,15 +15,12 @@ export default function ({ getService, getPageObjects }) { const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); const originalMarkdownText = 'Original markdown text'; const modifiedMarkdownText = 'Modified markdown text'; const createMarkdownVis = async (title) => { - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.dashboard.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); if (title) { diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index e2227d4240d409..44abf07b38ac65 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -55,6 +55,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('editFieldFormat'); await PageObjects.settings.setFieldType('Long'); await PageObjects.settings.changeFieldScript('emit(6);'); + await testSubjects.find('changeWarning'); await PageObjects.settings.clickSaveField(); await PageObjects.settings.confirmSave(); }); diff --git a/test/functional/apps/visualize/_data_table.ts b/test/functional/apps/visualize/_data_table.ts index 96cbf97621b089..1ff5bdcc6da78f 100644 --- a/test/functional/apps/visualize/_data_table.ts +++ b/test/functional/apps/visualize/_data_table.ts @@ -267,16 +267,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should apply correct filter', async () => { - await retry.try(async () => { - await PageObjects.visChart.filterOnTableCell(1, 3); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisContent(); - expect(data).to.be.eql([ - ['png', '1,373'], - ['gif', '918'], - ['Other', '445'], - ]); - }); + await PageObjects.visChart.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); }); }); diff --git a/test/functional/apps/visualize/_heatmap_chart.ts b/test/functional/apps/visualize/_heatmap_chart.ts index 79a9a6cbd5acae..660f45179631ed 100644 --- a/test/functional/apps/visualize/_heatmap_chart.ts +++ b/test/functional/apps/visualize/_heatmap_chart.ts @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/95642 - describe.skip('heatmap chart', function indexPatternCreation() { + describe('heatmap chart', function indexPatternCreation() { const vizName1 = 'Visualization HeatmapChart'; before(async function () { diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index 0a3632e4aaa814..747494a690c7ed 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -56,11 +56,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); - - // Test non-replaced vislib chart types - loadTestFile(require.resolve('./_gauge_chart')); - loadTestFile(require.resolve('./_heatmap_chart')); - loadTestFile(require.resolve('./_pie_chart')); }); describe('', function () { diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 9c12296db138c6..34559afdf6ae1a 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -413,6 +413,16 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.click('confirmSaveSavedObjectButton'); } + public async clickMarkdownQuickButton() { + log.debug('Click markdown quick button'); + await testSubjects.click('dashboardMarkdownQuickButton'); + } + + public async clickInputControlsQuickButton() { + log.debug('Click input controls quick button'); + await testSubjects.click('dashboardInputControlsQuickButton'); + } + /** * * @param dashboardTitle {String} diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index cd1c5cf318e63a..7b69101b92475c 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -419,12 +419,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async filterOnTableCell(columnIndex: number, rowIndex: number) { await retry.try(async () => { const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.focus(); + await cell.click(); const filterBtn = await testSubjects.findDescendant( 'tbvChartCell__filterForCellValue', cell ); - await filterBtn.click(); + await common.sleep(2000); + filterBtn.click(); }); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 5f05d825dd0f45..97627556abc630 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -128,9 +128,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } public async changeHeatmapColorNumbers(value = 6) { - const input = await testSubjects.find(`heatmapColorsNumber`); - await input.clearValueWithKeyboard(); - await input.type(`${value}`); + await testSubjects.setValue('heatmapColorsNumber', `${value}`); } public async getBucketErrorMessage() { diff --git a/x-pack/plugins/apm/common/i18n.ts b/x-pack/plugins/apm/common/i18n.ts index c5bbef0db244e4..8bce2acdf4dca8 100644 --- a/x-pack/plugins/apm/common/i18n.ts +++ b/x-pack/plugins/apm/common/i18n.ts @@ -13,10 +13,3 @@ export const NOT_AVAILABLE_LABEL = i18n.translate( defaultMessage: 'N/A', } ); - -export const UNIDENTIFIED_SERVICE_NODES_LABEL = i18n.translate( - 'xpack.apm.serviceNodeNameMissing', - { - defaultMessage: '(Empty)', - } -); diff --git a/x-pack/plugins/apm/common/service_nodes.ts b/x-pack/plugins/apm/common/service_nodes.ts index d744330f17b66f..ad75bd025069d4 100644 --- a/x-pack/plugins/apm/common/service_nodes.ts +++ b/x-pack/plugins/apm/common/service_nodes.ts @@ -5,4 +5,19 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; + export const SERVICE_NODE_NAME_MISSING = '_service_node_name_missing_'; + +const UNIDENTIFIED_SERVICE_NODES_LABEL = i18n.translate( + 'xpack.apm.serviceNodeNameMissing', + { + defaultMessage: '(Empty)', + } +); + +export function getServiceNodeName(serviceNodeName?: string) { + return serviceNodeName === SERVICE_NODE_NAME_MISSING || !serviceNodeName + ? UNIDENTIFIED_SERVICE_NODES_LABEL + : serviceNodeName; +} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index a7cbd7a79b4a7f..0ed9c5c919ddb1 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; +import { getServiceNodeName } from '../../../../../common/service_nodes'; import { APMRouteDefinition } from '../../../../application/routes'; import { toQuery } from '../../../shared/Links/url_helpers'; import { ErrorGroupDetails } from '../../ErrorGroupDetails'; @@ -294,15 +293,7 @@ export const routes: APMRouteDefinition[] = [ exact: true, path: '/services/:serviceName/nodes/:serviceNodeName/metrics', component: withApmServiceContext(ServiceNodeMetrics), - breadcrumb: ({ match }) => { - const { serviceNodeName } = match.params; - - if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { - return UNIDENTIFIED_SERVICE_NODES_LABEL; - } - - return serviceNodeName || ''; - }, + breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), }, { exact: true, diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index fc218f3ba6df30..3d284de621ea38 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -8,8 +8,10 @@ import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { + getServiceNodeName, + SERVICE_NODE_NAME_MISSING, +} from '../../../../common/service_nodes'; import { asDynamicBytes, asInteger, @@ -83,7 +85,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { const { displayedName, tooltip } = name === SERVICE_NODE_NAME_MISSING ? { - displayedName: UNIDENTIFIED_SERVICE_NODES_LABEL, + displayedName: getServiceNodeName(name), tooltip: i18n.translate( 'xpack.apm.jvmsTable.explainServiceNodeNameMissing', { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 13322b094c65ee..55eb2e3ddab732 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -13,19 +13,13 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { InstancesLatencyDistributionChart } from '../../shared/charts/instances_latency_distribution_chart'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceOverviewInstancesTable, TableOptions, } from './service_overview_instances_table'; -// We're hiding this chart until these issues are resolved in the 7.13 timeframe: -// -// * [[APM] Tooltips for instances latency distribution chart](https://github.com/elastic/kibana/issues/88852) -// * [[APM] x-axis on the instance bubble chart is broken](https://github.com/elastic/kibana/issues/92631) -// -// import { InstancesLatencyDistributionChart } from '../../shared/charts/instances_latency_distribution_chart'; - interface ServiceOverviewInstancesChartAndTableProps { chartHeight: number; serviceName: string; @@ -215,13 +209,13 @@ export function ServiceOverviewInstancesChartAndTable({ return ( <> - {/* + - */} + { + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + return datum.latency ?? 0; + }) + ); + return getDurationFormatter(maxLatency); +} + +export default { + title: 'shared/charts/InstancesLatencyDistributionChart/CustomTooltip', + component: CustomTooltip, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export function Example(props: TooltipInfo) { + return ( + + ); +} +Example.args = { + header: { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + yAccessor: '(index:0)', + splitAccessors: {}, + seriesKeys: ['(index:0)'], + }, + valueAccessor: 'y1', + label: 'Instances', + value: 9.473837632998105, + formattedValue: '9.473837632998105', + markValue: null, + color: '#6092c0', + isHighlighted: false, + isVisible: true, + datum: { + serviceNodeName: + '2f3221afa3f00d3bc07069d69efd5bd4c1607be6155a204551c8fe2e2b5dd750', + errorRate: 0.03496503496503497, + latency: 1057231.4125874126, + throughput: 9.473837632998105, + cpuUsage: 0.000033333333333333335, + memoryUsage: 0.18701022939403547, + }, + }, + values: [ + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 1057231.4125874126, + formattedValue: '1057231.4125874126', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '2f3221afa3f00d3bc07069d69efd5bd4c1607be6155a204551c8fe2e2b5dd750', + errorRate: 0.03496503496503497, + latency: 1057231.4125874126, + throughput: 9.473837632998105, + cpuUsage: 0.000033333333333333335, + memoryUsage: 0.18701022939403547, + }, + }, + ], +} as TooltipInfo; + +export function MultipleInstances(props: TooltipInfo) { + return ( + + ); +} +MultipleInstances.args = { + header: { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + yAccessor: '(index:0)', + splitAccessors: {}, + seriesKeys: ['(index:0)'], + }, + valueAccessor: 'y1', + label: 'Instances', + value: 9.606338858634443, + formattedValue: '9.606338858634443', + markValue: null, + color: '#6092c0', + isHighlighted: false, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + values: [ + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 56465.53793103448, + formattedValue: '56465.53793103448', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 56465.53793103448, + formattedValue: '56465.53793103448', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f (2)', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + ], +} as TooltipInfo; diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx new file mode 100644 index 00000000000000..2280fa91a659c0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TooltipInfo } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { getServiceNodeName } from '../../../../../common/service_nodes'; +import { + asTransactionRate, + TimeFormatter, +} from '../../../../../common/utils/formatters'; +import { useTheme } from '../../../../hooks/use_theme'; +import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; + +const latencyLabel = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipLatencyLabel', + { + defaultMessage: 'Latency', + } +); + +const throughputLabel = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipThroughputLabel', + { + defaultMessage: 'Throughput', + } +); + +const clickToFilterDescription = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipClickToFilterDescription', + { defaultMessage: 'Click to filter by instance' } +); + +/** + * Tooltip for a single instance + */ +function SingleInstanceCustomTooltip({ + latencyFormatter, + values, +}: { + latencyFormatter: TimeFormatter; + values: TooltipInfo['values']; +}) { + const value = values[0]; + const { color } = value; + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const { latency, serviceNodeName, throughput } = datum; + + return ( + <> +
+ {getServiceNodeName(serviceNodeName)} +
+
+
+
+
+
+
+ {latencyLabel} + + {latencyFormatter(latency).formatted} + +
+
+
+
+
+
+
+ {throughputLabel} + + {asTransactionRate(throughput)} + +
+
+
+ + ); +} + +/** + * Tooltip for a multiple instances + */ +function MultipleInstanceCustomTooltip({ + latencyFormatter, + values, +}: TooltipInfo & { latencyFormatter: TimeFormatter }) { + const theme = useTheme(); + + return ( + <> +
+ {i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipInstancesTitle', + { + defaultMessage: + '{instancesCount} {instancesCount, plural, one {instance} other {instances}}', + values: { instancesCount: values.length }, + } + )} +
+ {values.map((value) => { + const { color } = value; + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const { latency, serviceNodeName, throughput } = datum; + return ( +
+
+
+
+
+
+ + {getServiceNodeName(serviceNodeName)} + +
+
+
+
+
+
+
+ {latencyLabel} + + {latencyFormatter(latency).formatted} + +
+
+
+
+
+
+
+ {throughputLabel} + + {asTransactionRate(throughput)} + +
+
+
+ ); + })} + + ); +} + +/** + * Custom tooltip for instances latency distribution chart. + * + * The styling provided here recreates that in the Elastic Charts tooltip: https://github.com/elastic/elastic-charts/blob/58e6b5fbf77f4471d2a9a41c45a61f79ebd89b65/src/components/tooltip/tooltip.tsx + * + * We probably won't need to do all of this once https://github.com/elastic/elastic-charts/issues/615 is completed. + */ +export function CustomTooltip( + props: TooltipInfo & { latencyFormatter: TimeFormatter } +) { + const { values } = props; + const theme = useTheme(); + + return ( +
+ {values.length > 1 ? ( + + ) : ( + + )} +
+ {clickToFilterDescription} +
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 5bcf0d161653ee..57ecbd4ca0b78b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -9,14 +9,21 @@ import { Axis, BubbleSeries, Chart, + ElementClickListener, + GeometryValue, Position, ScaleType, Settings, + TooltipInfo, + TooltipProps, + TooltipType, } from '@elastic/charts'; import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { useChartTheme } from '../../../../../../observability/public'; +import { SERVICE_NODE_NAME } from '../../../../../common/elasticsearch_fieldnames'; import { asTransactionRate, getDurationFormatter, @@ -24,10 +31,12 @@ import { import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; +import * as urlHelpers from '../../Links/url_helpers'; import { ChartContainer } from '../chart_container'; import { getResponseTimeTickFormatter } from '../transaction_charts/helper'; +import { CustomTooltip } from './custom_tooltip'; -interface InstancesLatencyDistributionChartProps { +export interface InstancesLatencyDistributionChartProps { height: number; items?: PrimaryStatsServiceInstanceItem[]; status: FETCH_STATUS; @@ -38,6 +47,7 @@ export function InstancesLatencyDistributionChart({ items = [], status, }: InstancesLatencyDistributionChartProps) { + const history = useHistory(); const hasData = items.length > 0; const theme = useTheme(); @@ -51,6 +61,43 @@ export function InstancesLatencyDistributionChart({ const maxLatency = Math.max(...items.map((item) => item.latency ?? 0)); const latencyFormatter = getDurationFormatter(maxLatency); + const tooltip: TooltipProps = { + type: TooltipType.Follow, + snap: false, + customTooltip: (props: TooltipInfo) => ( + + ), + }; + + /** + * Handle click events on the items. + * + * Due to how we handle filtering by using the kuery bar, it's difficult to + * modify existing queries. If you have an existing query in the bar, this will + * wipe it out. This is ok for now, since we probably will be replacing this + * interaction with something nicer in a future release. + * + * The event object has an array two items for each point, one of which has + * the serviceNodeName, so we flatten the list and get the items we need to + * form a query. + */ + const handleElementClick: ElementClickListener = (event) => { + const serviceNodeNamesQuery = event + .flat() + .flatMap((value) => (value as GeometryValue).datum?.serviceNodeName) + .filter((serviceNodeName) => !!serviceNodeName) + .map((serviceNodeName) => `${SERVICE_NODE_NAME}:"${serviceNodeName}"`) + .join(' OR '); + + urlHelpers.push(history, { query: { kuery: serviceNodeNamesQuery } }); + }; + + // With a linear scale, if all the instances have similar throughput (or if + // there's just a single instance) they'll show along the origin. Make sure + // the x-axis domain is [0, maxThroughput]. + const maxThroughput = Math.max(...items.map((item) => item.throughput ?? 0)); + const xDomain = { min: 0, max: maxThroughput }; + return ( @@ -64,9 +111,11 @@ export function InstancesLatencyDistributionChart({ ( + + + + ), + ], +}; + +export function Example({ items }: InstancesLatencyDistributionChartProps) { + return ( + + ); +} +Example.args = { + items: [ + { + serviceNodeName: + '3f67bfc39c7891dc0c5657befb17bf58c19cf10f99472cf8df263c8e5bb1c766', + latency: 15802930.92133213, + throughput: 0.4019360641691481, + }, + { + serviceNodeName: + 'd52c64bea9327f3e960ac1cb63c1b7ea922e3cb3d76ab9b254e57a7cb2f760a0', + latency: 8296442.578550679, + throughput: 0.3932978392703585, + }, + { + serviceNodeName: + '797e0a906ad342223468ca51b663e1af8bdeb40bab376c46c7f7fa2021349290', + latency: 34842576.51204916, + throughput: 0.3353931699532713, + }, + { + serviceNodeName: + '21e1c648bd73434a8a1bf6e849817930e8b43eacf73a5c39c30520ee3b79d8c0', + latency: 40713854.354498595, + throughput: 0.32947224189485164, + }, + { + serviceNodeName: + 'a1c99c8675372af4c74bb01cc48e75989faa6f010a4ccb027df1c410dde0c72c', + latency: 18565471.348388012, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: '_service_node_name_missing_', + latency: 20065471.348388012, + throughput: 0.3261219384041683, + }, + ], +} as InstancesLatencyDistributionChartProps; + +export function SimilarThroughputInstances({ + items, +}: InstancesLatencyDistributionChartProps) { + return ( + + ); +} +SimilarThroughputInstances.args = { + items: [ + { + serviceNodeName: + '21e1c648bd73434a8a1bf6e849817930e8b43eacf73a5c39c30520ee3b79d8c0', + latency: 40713854.354498595, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: + 'a1c99c8675372af4c74bb01cc48e75989faa6f010a4ccb027df1c410dde0c72c', + latency: 18565471.348388012, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: '_service_node_name_missing_', + latency: 20065471.348388012, + throughput: 0.3261219384041683, + }, + ], +} as InstancesLatencyDistributionChartProps; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 6b94eccc4e7076..dcc39e9fb385a2 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -57,9 +57,11 @@ describe('Background Search Session Management Main', () => { describe('renders', () => { const docLinks: DocLinksStart = { - ELASTIC_WEBSITE_URL: 'boo/', - DOC_LINK_VERSION: '#foo', - links: {} as any, + ELASTIC_WEBSITE_URL: `boo/`, + DOC_LINK_VERSION: `#foo`, + links: { + elasticsearch: { asyncSearch: `mock-url` } as any, + } as any, }; let main: ReactWrapper; @@ -93,9 +95,7 @@ describe('Background Search Session Management Main', () => { test('documentation link', () => { const docLink = main.find('a[href]').first(); expect(docLink.text()).toBe('Documentation'); - expect(docLink.prop('href')).toBe( - 'boo/guide/en/elasticsearch/reference/#foo/async-search-intro.html' - ); + expect(docLink.prop('href')).toBe('mock-url'); }); test('table is present', () => { diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts index 19d37891446cff..38db89e88a6e1f 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts @@ -8,16 +8,15 @@ import { DocLinksStart } from 'kibana/public'; export class AsyncSearchIntroDocumentation { - private docsBasePath: string = ''; + private docUrl: string = ''; constructor(docs: DocLinksStart) { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docs; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + const { links } = docs; // TODO: There should be Kibana documentation link about Search Sessions in Kibana - this.docsBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; + this.docUrl = links.elasticsearch.asyncSearch; } public getElasticsearchDocLink() { - return `${this.docsBasePath}/async-search-intro.html`; + return `${this.docUrl}`; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts index 485ac19f2eb820..d16391089120a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -6,6 +6,7 @@ */ import { EngineDetails } from '../components/engine/types'; +import { ENGINES_TITLE } from '../components/engines'; import { generateEncodedPath } from '../utils/encode_path_params'; export const mockEngineValues = { @@ -20,6 +21,11 @@ export const mockEngineActions = { export const mockGenerateEnginePath = jest.fn((path, pathParams = {}) => generateEncodedPath(path, { engineName: mockEngineValues.engineName, ...pathParams }) ); +export const mockGetEngineBreadcrumbs = jest.fn((breadcrumbs = []) => [ + ENGINES_TITLE, + mockEngineValues.engineName, + ...breadcrumbs, +]); jest.mock('../components/engine', () => ({ EngineLogic: { @@ -27,4 +33,5 @@ jest.mock('../components/engine', () => ({ actions: mockEngineActions, }, generateEnginePath: mockGenerateEnginePath, + getEngineBreadcrumbs: mockGetEngineBreadcrumbs, })); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx index 3940151d3b7cdf..68f08d8d847245 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx @@ -18,7 +18,7 @@ import { AnalyticsRouter } from './'; describe('AnalyticsRouter', () => { // Detailed route testing is better done via E2E tests it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(9); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index 7bd4664cdbfa3a..397f1f1e1e1c38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -10,7 +10,6 @@ import { Route, Switch, Redirect } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_ANALYTICS_PATH, @@ -22,7 +21,7 @@ import { ENGINE_ANALYTICS_QUERY_DETAILS_PATH, ENGINE_ANALYTICS_QUERY_DETAIL_PATH, } from '../../routes'; -import { generateEnginePath } from '../engine'; +import { generateEnginePath, getEngineBreadcrumbs } from '../engine'; import { ANALYTICS_TITLE, @@ -42,11 +41,8 @@ import { QueryDetail, } from './views'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const AnalyticsRouter: React.FC = ({ engineBreadcrumb }) => { - const ANALYTICS_BREADCRUMB = [...engineBreadcrumb, ANALYTICS_TITLE]; +export const AnalyticsRouter: React.FC = () => { + const ANALYTICS_BREADCRUMB = getEngineBreadcrumbs([ANALYTICS_TITLE]); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx index 1945dde84ec450..cb29d92030ad7f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -7,10 +7,11 @@ import { setMockValues, setMockActions, rerender } from '../../../__mocks__'; import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { EuiPageHeader } from '@elastic/eui'; @@ -32,16 +33,14 @@ describe('ApiLogs', () => { pollForApiLogs: jest.fn(), }; - let wrapper: ShallowWrapper; - beforeEach(() => { jest.clearAllMocks(); setMockValues(values); setMockActions(actions); - wrapper = shallow(); }); it('renders', () => { + const wrapper = shallow(); expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); expect(wrapper.find(ApiLogsTable)).toHaveLength(1); expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1); @@ -52,13 +51,14 @@ describe('ApiLogs', () => { it('renders a loading screen', () => { setMockValues({ ...values, dataLoading: true, apiLogs: [] }); - rerender(wrapper); + const wrapper = shallow(); expect(wrapper.find(Loading)).toHaveLength(1); }); describe('effects', () => { it('calls a manual fetchApiLogs on page load and pagination', () => { + const wrapper = shallow(); expect(actions.fetchApiLogs).toHaveBeenCalledTimes(1); setMockValues({ ...values, meta: { page: { current: 2 } } }); @@ -68,6 +68,7 @@ describe('ApiLogs', () => { }); it('starts pollForApiLogs on page load', () => { + shallow(); expect(actions.pollForApiLogs).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx index 4690911fad7724..b8179163c93f94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -21,9 +21,9 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { Loading } from '../../../shared/loading'; +import { getEngineBreadcrumbs } from '../engine'; import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; import { ApiLogFlyout } from './api_log'; @@ -32,10 +32,7 @@ import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; import { ApiLogsLogic } from './'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { +export const ApiLogs: React.FC = () => { const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic); const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic); @@ -51,7 +48,7 @@ export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx index f0eafb13bb9b05..9598212d3e0c98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -14,7 +16,7 @@ import { CurationsRouter } from './'; describe('CurationsRouter', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(4); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx index e080f7de133906..28ce311b438875 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -10,23 +10,20 @@ import { Route, Switch } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_CURATIONS_PATH, ENGINE_CURATIONS_NEW_PATH, ENGINE_CURATION_PATH, } from '../../routes'; +import { getEngineBreadcrumbs } from '../engine'; import { CURATIONS_TITLE, CREATE_NEW_CURATION_TITLE } from './constants'; import { Curation } from './curation'; import { Curations, CurationCreation } from './views'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { - const CURATIONS_BREADCRUMB = [...engineBreadcrumb, CURATIONS_TITLE]; +export const CurationsRouter: React.FC = () => { + const CURATIONS_BREADCRUMB = getEngineBreadcrumbs([CURATIONS_TITLE]); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index a33161918c7f5f..c4563b43571345 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import '../../../__mocks__/react_router_history.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/react_router_history.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -44,17 +45,17 @@ describe('DocumentDetail', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiPageContent).length).toBe(1); }); it('initializes data on mount', () => { - shallow(); + shallow(); expect(actions.getDocumentDetails).toHaveBeenCalledWith('1'); }); it('calls setFields on unmount', () => { - shallow(); + shallow(); unmountHandler(); expect(actions.setFields).toHaveBeenCalledWith([]); }); @@ -65,7 +66,7 @@ describe('DocumentDetail', () => { dataLoading: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Loading).length).toBe(1); }); @@ -80,7 +81,7 @@ describe('DocumentDetail', () => { }; beforeEach(() => { - const wrapper = shallow(); + const wrapper = shallow(); columns = wrapper.find(EuiBasicTable).props().columns; }); @@ -101,7 +102,7 @@ describe('DocumentDetail', () => { }); it('will delete the document when the delete button is pressed', () => { - const wrapper = shallow(); + const wrapper = shallow(); const header = wrapper.find(EuiPageHeader).dive().children().dive(); const button = header.find('[data-test-subj="DeleteDocumentButton"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index fefe983df33420..314c3529cf4db7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -25,6 +25,7 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { useDecodedParams } from '../../utils/encode_path_params'; +import { getEngineBreadcrumbs } from '../engine'; import { ResultFieldValue } from '../result'; import { DOCUMENTS_TITLE } from './constants'; @@ -36,11 +37,8 @@ const DOCUMENT_DETAIL_TITLE = (documentId: string) => defaultMessage: 'Document: {documentId}', values: { documentId }, }); -interface Props { - engineBreadcrumb: string[]; -} -export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { +export const DocumentDetail: React.FC = () => { const { dataLoading, fields } = useValues(DocumentDetailLogic); const { deleteDocument, getDocumentDetails, setFields } = useActions(DocumentDetailLogic); @@ -77,7 +75,7 @@ export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { return ( <> - + { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SearchExperience).exists()).toBe(true); }); @@ -44,7 +45,7 @@ describe('Documents', () => { myRole: { canManageEngineDocuments: true }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); @@ -54,7 +55,7 @@ describe('Documents', () => { myRole: { canManageEngineDocuments: false }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); @@ -65,7 +66,7 @@ describe('Documents', () => { isMetaEngine: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); }); @@ -77,7 +78,7 @@ describe('Documents', () => { isMetaEngine: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); }); @@ -87,7 +88,7 @@ describe('Documents', () => { isMetaEngine: false, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index 84fcab53e96041..58aa6acc59783a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -16,23 +16,19 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { AppLogic } from '../../app_logic'; -import { EngineLogic } from '../engine'; +import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { DOCUMENTS_TITLE } from './constants'; import { DocumentCreationButton } from './document_creation_button'; import { SearchExperience } from './search_experience'; -interface Props { - engineBreadcrumb: string[]; -} - -export const Documents: React.FC = ({ engineBreadcrumb }) => { +export const Documents: React.FC = () => { const { isMetaEngine } = useValues(EngineLogic); const { myRole } = useValues(AppLogic); return ( <> - + { const { @@ -85,43 +84,41 @@ export const EngineRouter: React.FC = () => { const isLoadingNewEngine = engineName !== engineNameFromUrl; if (isLoadingNewEngine || dataLoading) return ; - const engineBreadcrumb = [ENGINES_TITLE, engineName]; - return ( {canViewEngineAnalytics && ( - + )} - + - + {canManageEngineCurations && ( - + )} {canManageEngineRelevanceTuning && ( - + )} {canManageEngineResultSettings && ( - + )} {canViewEngineApiLogs && ( - + )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts index 80c36822ccde0f..2a5b3351f41f70 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts @@ -8,4 +8,4 @@ export { EngineRouter } from './engine_router'; export { EngineNav } from './engine_nav'; export { EngineLogic } from './engine_logic'; -export { generateEnginePath } from './utils'; +export { generateEnginePath, getEngineBreadcrumbs } from './utils'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts index 867ed14fcc0527..be6b9a53bd0d5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts @@ -7,10 +7,12 @@ import { mockEngineValues } from '../../__mocks__'; -import { generateEnginePath } from './utils'; +import { generateEnginePath, getEngineBreadcrumbs } from './utils'; describe('generateEnginePath', () => { - mockEngineValues.engineName = 'hello-world'; + beforeEach(() => { + mockEngineValues.engineName = 'hello-world'; + }); it('generates paths with engineName filled from state', () => { expect(generateEnginePath('/engines/:engineName/example')).toEqual( @@ -27,3 +29,15 @@ describe('generateEnginePath', () => { ).toEqual('/engines/override/foo/baz'); }); }); + +describe('getEngineBreadcrumbs', () => { + beforeEach(() => { + mockEngineValues.engineName = 'foo'; + }); + + it('generates breadcrumbs with engineName filled from state', () => { + expect(getEngineBreadcrumbs(['bar', 'baz'])).toEqual(['Engines', 'foo', 'bar', 'baz']); + expect(getEngineBreadcrumbs(['bar'])).toEqual(['Engines', 'foo', 'bar']); + expect(getEngineBreadcrumbs()).toEqual(['Engines', 'foo']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts index 7b8521105875c6..820d89e4739222 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { generateEncodedPath } from '../../utils/encode_path_params'; +import { ENGINES_TITLE } from '../engines'; + import { EngineLogic } from './'; /** @@ -16,3 +19,11 @@ export const generateEnginePath = (path: string, pathParams: object = {}) => { const { engineName } = EngineLogic.values; return generateEncodedPath(path, { engineName, ...pathParams }); }; + +/** + * Generate a breadcrumb trail with engineName automatically filled from EngineLogic state + */ +export const getEngineBreadcrumbs = (breadcrumbs: BreadcrumbTrail = []) => { + const { engineName } = EngineLogic.values; + return [ENGINES_TITLE, engineName, ...breadcrumbs]; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts new file mode 100644 index 00000000000000..4ab9137436ffe2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('../../../../engines', () => ({ + EnginesLogic: { actions: { deleteEngine: jest.fn() } }, +})); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx new file mode 100644 index 00000000000000..5d91c724068e75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues, mockTelemetryActions } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; + +import { navigateToEngine, renderEngineLink } from './engine_link_helpers'; + +describe('navigateToEngine', () => { + const { navigateToUrl } = mockKibanaValues; + const { sendAppSearchTelemetry } = mockTelemetryActions; + + it('sends the user to the engine page and triggers a telemetry event', () => { + navigateToEngine('engine-a'); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/engine-a'); + expect(sendAppSearchTelemetry).toHaveBeenCalledWith({ + action: 'clicked', + metric: 'engine_table_link', + }); + }); +}); + +describe('renderEngineLink', () => { + const { sendAppSearchTelemetry } = mockTelemetryActions; + + it('renders a link to the engine with telemetry', () => { + const wrapper = shallow(
{renderEngineLink('engine-b')}
); + const link = wrapper.find(EuiLinkTo); + + expect(link.prop('to')).toEqual('/engines/engine-b'); + + link.simulate('click'); + expect(sendAppSearchTelemetry).toHaveBeenCalledWith({ + action: 'clicked', + metric: 'engine_table_link', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx new file mode 100644 index 00000000000000..a3350d1ef9939c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { TelemetryLogic } from '../../../../../shared/telemetry'; +import { ENGINE_PATH } from '../../../../routes'; +import { generateEncodedPath } from '../../../../utils/encode_path_params'; + +const sendEngineTableLinkClickTelemetry = () => { + TelemetryLogic.actions.sendAppSearchTelemetry({ + action: 'clicked', + metric: 'engine_table_link', + }); +}; + +export const navigateToEngine = (engineName: string) => { + sendEngineTableLinkClickTelemetry(); + KibanaLogic.values.navigateToUrl(generateEncodedPath(ENGINE_PATH, { engineName })); +}; + +export const renderEngineLink = (engineName: string) => ( + + {engineName} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx new file mode 100644 index 00000000000000..8d3b4b2a5e6cac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl, setMockValues } from '../../../../../__mocks__'; +import '../../../../../__mocks__/enterprise_search_url.mock'; +import './__mocks__/engines_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { EnginesTable } from './engines_table'; + +import { runSharedColumnsTests, runSharedPropsTests } from './test_helpers'; + +describe('EnginesTable', () => { + const data = [ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: false, + document_count: 99999, + field_count: 10, + } as EngineDetails, + ]; + const props = { + items: data, + loading: false, + pagination: { + pageIndex: 0, + pageSize: 10, + totalItemCount: 1, + hidePerPageOptions: true, + }, + onChange: () => {}, + }; + setMockValues({ myRole: { canManageEngines: false } }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('columns', () => { + const wrapper = shallow(); + const tableContent = mountWithIntl() + .find(EuiBasicTable) + .text(); + runSharedColumnsTests(wrapper, tableContent); + }); + + describe('language column', () => { + it('renders language when set', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find(EuiBasicTable).text()).toContain('German'); + }); + + it('renders the language as Universal if no language is set', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find(EuiBasicTable).text()).toContain('Universal'); + }); + }); + + describe('passed props', () => { + const wrapper = shallow(); + runSharedPropsTests(wrapper); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx new file mode 100644 index 00000000000000..563e272a4a7303 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AppLogic } from '../../../../app_logic'; +import { UNIVERSAL_LANGUAGE } from '../../../../constants'; +import { EngineDetails } from '../../../engine/types'; + +import { renderEngineLink } from './engine_link_helpers'; +import { + ACTIONS_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; +import { EnginesTableProps } from './types'; + +const LANGUAGE_COLUMN: EuiTableFieldDataColumnType = { + field: 'language', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language', { + defaultMessage: 'Language', + }), + dataType: 'string', + render: (language: string) => language || UNIVERSAL_LANGUAGE, +}; + +export const EnginesTable: React.FC = ({ + items, + loading, + noItemsMessage, + pagination, + onChange, +}) => { + const { + myRole: { canManageEngines }, + } = useValues(AppLogic); + + const columns: Array> = [ + { + ...NAME_COLUMN, + render: (name: string) => renderEngineLink(name), + }, + CREATED_AT_COLUMN, + LANGUAGE_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + ]; + + if (canManageEngines) { + columns.push(ACTIONS_COLUMN); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx new file mode 100644 index 00000000000000..430539c10bbf37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl, setMockValues } from '../../../../../__mocks__'; +import '../../../../../__mocks__/enterprise_search_url.mock'; +import './__mocks__/engines_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTable } from './meta_engines_table'; +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; + +import { runSharedColumnsTests, runSharedPropsTests } from './test_helpers'; + +describe('MetaEnginesTable', () => { + const data = [ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + isMeta: true, + document_count: 99999, + field_count: 10, + includedEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + } as EngineDetails, + ]; + const props = { + items: data, + loading: false, + pagination: { + pageIndex: 0, + pageSize: 10, + totalItemCount: 1, + hidePerPageOptions: true, + }, + onChange: () => {}, + }; + + const DEFAULT_VALUES = { + myRole: { + canManageMetaEngines: false, + }, + expandedSourceEngines: {}, + hideRow: jest.fn(), + fetchOrDisplayRow: jest.fn(), + }; + setMockValues(DEFAULT_VALUES); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('columns', () => { + const wrapper = shallow(); + const tableContent = mountWithIntl() + .find(EuiBasicTable) + .text(); + runSharedColumnsTests(wrapper, tableContent, DEFAULT_VALUES); + }); + + describe('passed props', () => { + const wrapper = shallow(); + runSharedPropsTests(wrapper); + }); + + describe('expanded source engines', () => { + it('is hidden by default', () => { + const wrapper = shallow(); + const table = wrapper.find(EuiBasicTable).dive(); + + expect(table.find(MetaEnginesTableNameColumnContent)).toHaveLength(1); + expect(table.find(MetaEnginesTableExpandedRow)).toHaveLength(0); + }); + + it('is visible when the row has been expanded', () => { + setMockValues({ + ...DEFAULT_VALUES, + expandedSourceEngines: { 'test-engine': true }, + }); + const wrapper = shallow(); + const table = wrapper.find(EuiBasicTable); + expect(table.dive().find(MetaEnginesTableExpandedRow)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx new file mode 100644 index 00000000000000..f99dc7e15eaec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode, useMemo } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; + +import { AppLogic } from '../../../../app_logic'; +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; +import { MetaEnginesTableLogic } from './meta_engines_table_logic'; +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; +import { + ACTIONS_COLUMN, + BLANK_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; +import { EnginesTableProps } from './types'; +import { getConflictingEnginesSet } from './utils'; + +interface IItemIdToExpandedRowMap { + [id: string]: ReactNode; +} + +export interface ConflictingEnginesSets { + [key: string]: Set; +} + +export const MetaEnginesTable: React.FC = ({ + items, + loading, + noItemsMessage, + pagination, + onChange, +}) => { + const { expandedSourceEngines } = useValues(MetaEnginesTableLogic); + const { hideRow, fetchOrDisplayRow } = useActions(MetaEnginesTableLogic); + const { + myRole: { canManageMetaEngines }, + } = useValues(AppLogic); + + const conflictingEnginesSets: ConflictingEnginesSets = useMemo( + () => + items.reduce((accumulator, metaEngine) => { + return { + ...accumulator, + [metaEngine.name]: getConflictingEnginesSet(metaEngine), + }; + }, {}), + [items] + ); + + const itemIdToExpandedRowMap: IItemIdToExpandedRowMap = useMemo( + () => + Object.keys(expandedSourceEngines).reduce((accumulator, engineName) => { + return { + ...accumulator, + [engineName]: ( + + ), + }; + }, {}), + [expandedSourceEngines, conflictingEnginesSets] + ); + + const columns: Array> = [ + { + ...NAME_COLUMN, + render: (_, item: EngineDetails) => ( + + ), + }, + CREATED_AT_COLUMN, + BLANK_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + ]; + + if (canManageMetaEngines) { + columns.push(ACTIONS_COLUMN); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss new file mode 100644 index 00000000000000..e6f627458f43e7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss @@ -0,0 +1,21 @@ +.metaEnginesSourceEnginesTable { + margin: (-$euiSizeS) (-$euiSizeS) $euiSizeS (-$euiSizeS); + + thead { + display: none; + } + + @include euiBreakpoint('l', 'xl') { + .euiTableRowCell { + border-top: none; + } + + .euiTitle { + display: none; + } + } + + .euiTableHeaderMobile { + display: none + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx new file mode 100644 index 00000000000000..dcaa1a2b7c2461 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable, EuiHealth } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; + +const SOURCE_ENGINES = [ + { + name: 'source-engine-1', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: true, + document_count: 99999, + field_count: 10, + }, + { + name: 'source-engine-2', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: true, + document_count: 55555, + field_count: 7, + }, +] as EngineDetails[]; + +describe('MetaEnginesTableExpandedRow', () => { + it('contains relevant source engine information', () => { + const wrapper = mountWithIntl( + + ); + const table = wrapper.find(EuiBasicTable); + + expect(table).toHaveLength(1); + + const tableContent = table.text(); + expect(tableContent).toContain('source-engine-1'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(tableContent).toContain('source-engine-2'); + expect(tableContent).toContain('55,555'); + expect(tableContent).toContain('7'); + }); + + it('indicates when a meta-engine has conflicts', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(EuiBasicTable); + expect(table.dive().find(EuiHealth)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx new file mode 100644 index 00000000000000..0f974581ca73cd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBasicTable, EuiHealth, EuiTitle } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; +import { SOURCE_ENGINES_TITLE } from '../../constants'; + +import { + BLANK_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; + +import './meta_engines_table_expanded_row.scss'; + +interface MetaEnginesTableExpandedRowProps { + sourceEngines: EngineDetails[]; + conflictingEngines: Set; +} + +export const MetaEnginesTableExpandedRow: React.FC = ({ + sourceEngines, + conflictingEngines, +}) => ( +
+ +

{SOURCE_ENGINES_TITLE}

+
+ ( + <> + {conflictingEngines.has(engineDetails.name) ? ( + {engineDetails.field_count} + ) : ( + engineDetails.field_count + )} + + ), + }, + BLANK_COLUMN, + ]} + /> +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts new file mode 100644 index 00000000000000..b90207331ffd66 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableLogic } from './meta_engines_table_logic'; + +describe('MetaEnginesTableLogic', () => { + const DEFAULT_VALUES = { + expandedRows: {}, + sourceEngines: {}, + expandedSourceEngines: {}, + }; + + const SOURCE_ENGINES = [ + { + name: 'source-engine-1', + }, + { + name: 'source-engine-2', + }, + ] as EngineDetails[]; + + const META_ENGINES = [ + { + name: 'test-engine-1', + includedEngines: SOURCE_ENGINES, + }, + { + name: 'test-engine-2', + includedEngines: SOURCE_ENGINES, + }, + ] as EngineDetails[]; + + const DEFAULT_PROPS = { + metaEngines: [...SOURCE_ENGINES, ...META_ENGINES] as EngineDetails[], + }; + + const { http } = mockHttpValues; + const { mount } = new LogicMounter(MetaEnginesTableLogic); + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', async () => { + mount({}, DEFAULT_PROPS); + expect(MetaEnginesTableLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('reducers', () => { + describe('expandedRows', () => { + it('displayRow adds an expanded row entry for provided itemId', () => { + mount(DEFAULT_VALUES, DEFAULT_PROPS); + MetaEnginesTableLogic.actions.displayRow('source-engine-1'); + + expect(MetaEnginesTableLogic.values.expandedRows).toEqual({ + 'source-engine-1': true, + }); + }); + + it('hideRow removes any expanded row entry for provided itemId', () => { + mount({ ...DEFAULT_VALUES, expandedRows: { 'source-engine-1': true } }, DEFAULT_PROPS); + + MetaEnginesTableLogic.actions.hideRow('source-engine-1'); + + expect(MetaEnginesTableLogic.values.expandedRows).toEqual({}); + }); + }); + + it('sourceEngines is updated by addSourceEngines', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + }); + + MetaEnginesTableLogic.actions.addSourceEngines({ + 'test-engine-2': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }); + + expect(MetaEnginesTableLogic.values.sourceEngines).toEqual({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + 'test-engine-2': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + }); + }); + + describe('listeners', () => { + describe('fetchOrDisplayRow', () => { + it('calls displayRow when it already has data for the itemId', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + }); + jest.spyOn(MetaEnginesTableLogic.actions, 'displayRow'); + + MetaEnginesTableLogic.actions.fetchOrDisplayRow('test-engine-1'); + + expect(MetaEnginesTableLogic.actions.displayRow).toHaveBeenCalled(); + }); + + it('calls fetchSourceEngines when it needs to fetch data for the itemId', () => { + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + mount(); + jest.spyOn(MetaEnginesTableLogic.actions, 'fetchSourceEngines'); + + MetaEnginesTableLogic.actions.fetchOrDisplayRow('test-engine-1'); + + expect(MetaEnginesTableLogic.actions.fetchSourceEngines).toHaveBeenCalled(); + }); + }); + + describe('fetchSourceEngines', () => { + it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => { + mount(); + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + jest.spyOn(MetaEnginesTableLogic.actions, 'displayRow'); + jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines'); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine-1/source_engines', + { + query: { + 'page[current]': 1, + 'page[size]': 25, + }, + } + ); + expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + expect(MetaEnginesTableLogic.actions.displayRow).toHaveBeenCalledWith('test-engine-1'); + }); + + it('display a flash message on error', async () => { + http.get.mockReturnValueOnce(Promise.reject()); + mount(); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + + it('recursively fetches a number of pages', async () => { + mount(); + jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines'); + + // First page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-1' }], + }) + ); + + // Second and final page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-2' }], + }) + ); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({ + 'test-engine-1': [ + // First page + { name: 'source-engine-1' }, + // Second and final page + { name: 'source-engine-2' }, + ], + }); + }); + }); + }); + + describe('selectors', () => { + it('expandedSourceEngines includes all source engines that have been expanded ', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + 'test-engine-2': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + expandedRows: { + 'test-engine-1': true, + }, + }); + + expect(MetaEnginesTableLogic.values.expandedSourceEngines).toEqual({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts new file mode 100644 index 00000000000000..04e1ee5c1b61ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../../../common/types'; +import { flashAPIErrors } from '../../../../../shared/flash_messages'; + +import { HttpLogic } from '../../../../../shared/http'; + +import { EngineDetails } from '../../../engine/types'; + +interface MetaEnginesTableValues { + expandedRows: { [id: string]: boolean }; + sourceEngines: { [id: string]: EngineDetails[] }; + expandedSourceEngines: { [id: string]: EngineDetails[] }; +} + +interface MetaEnginesTableActions { + addSourceEngines( + sourceEngines: MetaEnginesTableValues['sourceEngines'] + ): { sourceEngines: MetaEnginesTableValues['sourceEngines'] }; + displayRow(itemId: string): { itemId: string }; + fetchOrDisplayRow(itemId: string): { itemId: string }; + fetchSourceEngines(engineName: string): { engineName: string }; + hideRow(itemId: string): { itemId: string }; +} + +interface EnginesAPIResponse { + results: EngineDetails[]; + meta: Meta; +} + +export const MetaEnginesTableLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'meta_engines_table_logic'], + actions: () => ({ + addSourceEngines: (sourceEngines) => ({ sourceEngines }), + displayRow: (itemId) => ({ itemId }), + hideRow: (itemId) => ({ itemId }), + fetchOrDisplayRow: (itemId) => ({ itemId }), + fetchSourceEngines: (engineName) => ({ engineName }), + }), + reducers: () => ({ + expandedRows: [ + {}, + { + displayRow: (expandedRows, { itemId }) => ({ + ...expandedRows, + [itemId]: true, + }), + hideRow: (expandedRows, { itemId }) => { + const newRows = { ...expandedRows }; + delete newRows[itemId]; + return newRows; + }, + }, + ], + sourceEngines: [ + {}, + { + addSourceEngines: (currentSourceEngines, { sourceEngines: newSourceEngines }) => ({ + ...currentSourceEngines, + ...newSourceEngines, + }), + }, + ], + }), + selectors: { + expandedSourceEngines: [ + (selectors) => [selectors.sourceEngines, selectors.expandedRows], + (sourceEngines: MetaEnginesTableValues['sourceEngines'], expandedRows: string[]) => { + return Object.keys(expandedRows).reduce((expandedRowMap, engineName) => { + expandedRowMap[engineName] = sourceEngines[engineName]; + return expandedRowMap; + }, {} as MetaEnginesTableValues['sourceEngines']); + }, + ], + }, + listeners: ({ actions, values }) => ({ + fetchOrDisplayRow: ({ itemId }) => { + const sourceEngines = values.sourceEngines; + if (sourceEngines[itemId]) { + actions.displayRow(itemId); + } else { + actions.fetchSourceEngines(itemId); + } + }, + fetchSourceEngines: ({ engineName }) => { + const { http } = HttpLogic.values; + + let enginesAccumulator: EngineDetails[] = []; + + const recursiveFetchSourceEngines = async (page = 1) => { + try { + const { meta, results }: EnginesAPIResponse = await http.get( + `/api/app_search/engines/${engineName}/source_engines`, + { + query: { + 'page[current]': page, + 'page[size]': 25, + }, + } + ); + + enginesAccumulator = [...enginesAccumulator, ...results]; + + if (page >= meta.page.total_pages) { + actions.addSourceEngines({ [engineName]: enginesAccumulator }); + actions.displayRow(engineName); + } else { + recursiveFetchSourceEngines(page + 1); + } + } catch (e) { + flashAPIErrors(e); + } + }; + + recursiveFetchSourceEngines(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx new file mode 100644 index 00000000000000..df65f2f86e1749 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiHealth } from '@elastic/eui'; + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; + +describe('MetaEnginesTableNameColumnContent', () => { + it('includes the name of the engine', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('[data-test-subj="EngineName"]')).toHaveLength(1); + }); + + describe('toggle button', () => { + it('displays expanded row when the row is currently hidden', () => { + const showRow = jest.fn(); + + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="ExpandRowButton"]').at(0).simulate('click'); + + expect(showRow).toHaveBeenCalled(); + }); + + it('hides expanded row when the row is currently visible', () => { + const hideRow = jest.fn(); + + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="ExpandRowButton"]').at(0).simulate('click'); + + expect(hideRow).toHaveBeenCalled(); + }); + }); + + describe('engine count', () => { + it('is included and labelled', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('[data-test-subj="SourceEnginesCount"]')).toHaveLength(1); + }); + }); + + it('indicates the precense of field-type conflicts', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiHealth)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx new file mode 100644 index 00000000000000..e05246ab4d92cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiIcon, EuiHealth, EuiFlexItem } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { EngineDetails } from '../../../engine/types'; + +import { renderEngineLink } from './engine_link_helpers'; + +interface MetaEnginesTableNameContentProps { + isExpanded: boolean; + item: EngineDetails; + hideRow: (name: string) => void; + showRow: (name: string) => void; +} + +export const MetaEnginesTableNameColumnContent: React.FC = ({ + item: { name, schemaConflicts, engine_count: engineCount }, + isExpanded, + hideRow, + showRow, +}) => ( + + {renderEngineLink(name)} + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx new file mode 100644 index 00000000000000..3375b25cdcd6ca --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedNumber } from '@kbn/i18n/react'; + +import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { FormattedDateTime } from '../../../../utils/formatted_date_time'; +import { EngineDetails } from '../../../engine/types'; +import { EnginesLogic } from '../../../engines'; + +import { navigateToEngine } from './engine_link_helpers'; + +export const BLANK_COLUMN: EuiTableComputedColumnType = { + render: () => <>, + 'aria-hidden': true, +}; + +export const NAME_COLUMN: EuiTableFieldDataColumnType = { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + width: '30%', + truncateText: true, + mobileOptions: { + header: true, + // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error + // @ts-ignore + enlarge: true, + width: '100%', + truncateText: false, + }, +}; + +export const CREATED_AT_COLUMN: EuiTableFieldDataColumnType = { + field: 'created_at', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', { + defaultMessage: 'Created at', + }), + dataType: 'string', + render: (dateString: string) => , +}; + +export const DOCUMENT_COUNT_COLUMN: EuiTableFieldDataColumnType = { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, +}; + +export const FIELD_COUNT_COLUMN: EuiTableFieldDataColumnType = { + field: 'field_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', { + defaultMessage: 'Field count', + }), + dataType: 'number', + render: (number: number) => , + truncateText: true, +}; + +export const ACTIONS_COLUMN: EuiTableActionsColumnType = { + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: MANAGE_BUTTON_LABEL, + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage.buttonDescription', + { + defaultMessage: 'Manage this engine', + } + ), + type: 'icon', + icon: 'eye', + onClick: (engineDetails) => navigateToEngine(engineDetails.name), + }, + { + name: DELETE_BUTTON_LABEL, + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.buttonDescription', + { + defaultMessage: 'Delete this engine', + } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (engine) => { + if ( + window.confirm( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.confirmationPopupMessage', + { + defaultMessage: + 'Are you sure you want to permanently delete "{engineName}" and all of its content?', + values: { + engineName: engine.name, + }, + } + ) + ) + ) { + EnginesLogic.actions.deleteEngine(engine); + } + }, + }, + ], +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts new file mode 100644 index 00000000000000..c2989c5d1f9725 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { runSharedColumnsTests } from './shared_columns'; +export { runSharedPropsTests } from './shared_props'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx new file mode 100644 index 00000000000000..97e2057cea2d96 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, rerender } from '../../../../../../__mocks__'; +import '../__mocks__/engines_logic.mock'; + +import { ShallowWrapper } from 'enzyme'; + +import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; + +import { EnginesLogic } from '../../../../engines'; + +import * as engineLinkHelpers from '../engine_link_helpers'; + +export const runSharedColumnsTests = ( + wrapper: ShallowWrapper, + tableContent: string, + values: object = {} +) => { + const getTable = () => wrapper.find(EuiBasicTable).dive(); + + describe('name column', () => { + it('renders', () => { + expect(tableContent).toContain('test-engine'); + }); + + // Link behavior is tested in engine_link_helpers.test.tsx + }); + + describe('created at column', () => { + it('renders', () => { + expect(tableContent).toContain('Created at'); + expect(tableContent).toContain('Jan 1, 1970'); + }); + }); + + describe('document count column', () => { + it('renders', () => { + expect(tableContent).toContain('Document count'); + expect(tableContent).toContain('99,999'); + }); + }); + + describe('field count column', () => { + it('renders', () => { + expect(tableContent).toContain('Field count'); + expect(tableContent).toContain('10'); + }); + }); + + describe('actions column', () => { + const getActions = () => getTable().find('ExpandedItemActions'); + const getActionItems = () => getActions().dive().find('DefaultItemAction'); + + it('will hide the action buttons if the user cannot manage/delete engines', () => { + setMockValues({ + ...values, + myRole: { canManageEngines: false, canManageMetaEngines: false }, + }); + rerender(wrapper); + expect(getActions()).toHaveLength(0); + }); + + describe('when the user can manage/delete engines', () => { + const getManageAction = () => getActionItems().at(0).dive().find(EuiButtonIcon); + const getDeleteAction = () => getActionItems().at(1).dive().find(EuiButtonIcon); + + beforeAll(() => { + setMockValues({ + ...values, + myRole: { canManageEngines: true, canManageMetaEngines: true }, + }); + rerender(wrapper); + }); + + describe('manage action', () => { + it('sends the user to the engine overview on click', () => { + jest.spyOn(engineLinkHelpers, 'navigateToEngine'); + const { navigateToEngine } = engineLinkHelpers; + getManageAction().simulate('click'); + + expect(navigateToEngine).toHaveBeenCalledWith('test-engine'); + }); + }); + + describe('delete action', () => { + const { deleteEngine } = EnginesLogic.actions; + + it('clicking the action and confirming deletes the engine', () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(true); + getDeleteAction().simulate('click'); + + expect(deleteEngine).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-engine' }) + ); + }); + + it('clicking the action and not confirming does not delete the engine', () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(false); + getDeleteAction().simulate('click'); + + expect(deleteEngine).not.toHaveBeenCalled(); + }); + }); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx new file mode 100644 index 00000000000000..0b0a8a0a995930 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ShallowWrapper } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +export const runSharedPropsTests = (wrapper: ShallowWrapper) => { + it('passes the loading prop', () => { + wrapper.setProps({ loading: true }); + expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true); + }); + + it('passes the noItemsMessage prop', () => { + wrapper.setProps({ noItemsMessage: 'No items.' }); + expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); + }); + + describe('pagination', () => { + it('passes the pagination prop', () => { + const pagination = { + pageIndex: 0, + pageSize: 10, + totalItemCount: 50, + }; + wrapper.setProps({ pagination }); + expect(wrapper.find(EuiBasicTable).prop('pagination')).toEqual(pagination); + }); + + it('triggers onChange', () => { + const onChange = jest.fn(); + wrapper.setProps({ onChange }); + + wrapper.find(EuiBasicTable).simulate('change', { page: { index: 4 } }); + expect(onChange).toHaveBeenCalledWith({ page: { index: 4 } }); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts new file mode 100644 index 00000000000000..707c086e01827f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; + +import { CriteriaWithPagination } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +export interface EnginesTableProps { + items: EngineDetails[]; + loading: boolean; + noItemsMessage?: ReactNode; + pagination: { + pageIndex: number; + pageSize: number; + totalItemCount: number; + hidePerPageOptions: boolean; + }; + onChange(criteria: CriteriaWithPagination): void; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts new file mode 100644 index 00000000000000..f65a2e52bae064 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +import { + getConflictingEnginesFromConflictingField, + getConflictingEnginesFromSchemaConflicts, + getConflictingEnginesSet, +} from './utils'; + +describe('getConflictingEnginesFromConflictingField', () => { + const CONFLICTING_FIELD: SchemaConflictFieldTypes = { + text: ['source-engine-1'], + number: ['source-engine-2', 'source-engine-3'], + geolocation: ['source-engine-4'], + date: ['source-engine-5', 'source-engine-6'], + }; + + it('returns a flat array of all engines with conflicts across different schema types, including duplicates', () => { + const result = getConflictingEnginesFromConflictingField(CONFLICTING_FIELD); + + // we can't guarantee ordering + expect(result).toHaveLength(6); + expect(result).toContain('source-engine-1'); + expect(result).toContain('source-engine-2'); + expect(result).toContain('source-engine-3'); + expect(result).toContain('source-engine-4'); + expect(result).toContain('source-engine-5'); + expect(result).toContain('source-engine-6'); + }); +}); + +describe('getConflictingEnginesFromSchemaConflicts', () => { + it('returns a flat array of all engines with conflicts across all fields, including duplicates', () => { + const SCHEMA_CONFLICTS: SchemaConflicts = { + 'conflicting-field-1': { + text: ['source-engine-1'], + number: ['source-engine-2'], + geolocation: [], + date: [], + }, + 'conflicting-field-2': { + text: [], + number: [], + geolocation: ['source-engine-2'], + date: ['source-engine-3'], + }, + }; + + const result = getConflictingEnginesFromSchemaConflicts(SCHEMA_CONFLICTS); + + // we can't guarantee ordering + expect(result).toHaveLength(4); + expect(result).toContain('source-engine-1'); + expect(result).toContain('source-engine-2'); + expect(result).toContain('source-engine-3'); + }); +}); + +describe('getConflictingEnginesSet', () => { + const DEFAULT_META_ENGINE_DETAILS = { + name: 'test-engine-1', + includedEngines: [ + { + name: 'source-engine-1', + }, + { + name: 'source-engine-2', + }, + { + name: 'source-engine-3', + }, + ] as EngineDetails[], + schemaConflicts: { + 'conflicting-field-1': { + text: ['source-engine-1'], + number: ['source-engine-2'], + geolocation: [], + date: [], + }, + 'conflicting-field-2': { + text: [], + number: [], + geolocation: ['source-engine-2'], + date: ['source-engine-3'], + }, + } as SchemaConflicts, + } as EngineDetails; + + it('generates a set of engine names with any field conflicts for the meta-engine', () => { + expect(getConflictingEnginesSet(DEFAULT_META_ENGINE_DETAILS)).toEqual( + new Set(['source-engine-1', 'source-engine-2', 'source-engine-3']) + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts new file mode 100644 index 00000000000000..b1172237e3ad30 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +export const getConflictingEnginesFromConflictingField = ( + conflictingField: SchemaConflictFieldTypes +): string[] => Object.values(conflictingField).flat(); + +export const getConflictingEnginesFromSchemaConflicts = ( + schemaConflicts: SchemaConflicts +): string[] => Object.values(schemaConflicts).flatMap(getConflictingEnginesFromConflictingField); + +// Given a meta-engine (represented by IEngineDetails), generate a Set of all source engines +// who have schema conflicts in the context of that meta-engine +// +// A Set allows us to enforce uniqueness and has O(1) lookup time +export const getConflictingEnginesSet = (metaEngine: EngineDetails): Set => { + const conflictingEngines: string[] = metaEngine.schemaConflicts + ? getConflictingEnginesFromSchemaConflicts(metaEngine.schemaConflicts) + : []; + return new Set(conflictingEngines); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts index 1955084393e570..c6c077e984efe7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts @@ -16,6 +16,11 @@ export const META_ENGINES_TITLE = i18n.translate( { defaultMessage: 'Meta Engines' } ); +export const SOURCE_ENGINES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.metaEnginesTable.sourceEngines.title', + { defaultMessage: 'Source Engines' } +); + export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate( 'xpack.enterpriseSearch.appSearch.engines.createAnEngineButton.ButtonLabel', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index 3ca039907932ee..c47b169ede3644 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -15,7 +15,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { EuiEmptyPrompt } from '@elastic/eui'; import { LoadingState, EmptyState } from './components'; -import { EnginesTable } from './engines_table'; +import { EnginesTable } from './components/tables/engines_table'; +import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { EnginesOverview } from './'; @@ -41,7 +42,11 @@ describe('EnginesOverview', () => { }, metaEnginesLoading: false, hasPlatinumLicense: false, + // AppLogic myRole: { canManageEngines: false }, + // MetaEnginesTableLogic + expandedSourceEngines: {}, + conflictingEnginesSets: {}, }; const actions = { loadEngines: jest.fn(), @@ -120,7 +125,7 @@ describe('EnginesOverview', () => { }); const wrapper = shallow(); - expect(wrapper.find(EnginesTable)).toHaveLength(2); + expect(wrapper.find(MetaEnginesTable)).toHaveLength(1); expect(actions.loadMetaEngines).toHaveBeenCalled(); }); @@ -147,7 +152,7 @@ describe('EnginesOverview', () => { metaEngines: [], }); const wrapper = shallow(); - const metaEnginesTable = wrapper.find(EnginesTable).last().dive(); + const metaEnginesTable = wrapper.find(MetaEnginesTable).dive(); const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive(); expect( @@ -199,10 +204,10 @@ describe('EnginesOverview', () => { const wrapper = shallow(); const pageEvent = { page: { index: 0 } }; - wrapper.find(EnginesTable).first().simulate('change', pageEvent); + wrapper.find(EnginesTable).simulate('change', pageEvent); expect(actions.onEnginesPagination).toHaveBeenCalledWith(1); - wrapper.find(EnginesTable).last().simulate('change', pageEvent); + wrapper.find(MetaEnginesTable).simulate('change', pageEvent); expect(actions.onMetaEnginesPagination).toHaveBeenCalledWith(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index d7e2309fd2a07e..4e17278d25d1a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -29,6 +29,8 @@ import { EngineIcon, MetaEngineIcon } from '../../icons'; import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; import { EnginesOverviewHeader, LoadingState, EmptyState } from './components'; +import { EnginesTable } from './components/tables/engines_table'; +import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { CREATE_AN_ENGINE_BUTTON_LABEL, CREATE_A_META_ENGINE_BUTTON_LABEL, @@ -38,7 +40,6 @@ import { META_ENGINES_TITLE, } from './constants'; import { EnginesLogic } from './engines_logic'; -import { EnginesTable } from './engines_table'; import './engines_overview.scss'; @@ -58,13 +59,9 @@ export const EnginesOverview: React.FC = () => { metaEnginesLoading, } = useValues(EnginesLogic); - const { - deleteEngine, - loadEngines, - loadMetaEngines, - onEnginesPagination, - onMetaEnginesPagination, - } = useActions(EnginesLogic); + const { loadEngines, loadMetaEngines, onEnginesPagination, onMetaEnginesPagination } = useActions( + EnginesLogic + ); useEffect(() => { loadEngines(); @@ -116,7 +113,6 @@ export const EnginesOverview: React.FC = () => { hidePerPageOptions: true, }} onChange={handlePageChange(onEnginesPagination)} - onDeleteEngine={deleteEngine} /> @@ -146,7 +142,7 @@ export const EnginesOverview: React.FC = () => { - { /> } onChange={handlePageChange(onMetaEnginesPagination)} - onDeleteEngine={deleteEngine} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx deleted file mode 100644 index fc37c3543af569..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions, mountWithIntl, setMockValues } from '../../../__mocks__'; - -import React from 'react'; - -import { ReactWrapper, shallow } from 'enzyme'; - -import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiIcon, EuiTableRow } from '@elastic/eui'; - -import { KibanaLogic } from '../../../shared/kibana'; -import { EuiLinkTo } from '../../../shared/react_router_helpers'; - -import { TelemetryLogic } from '../../../shared/telemetry'; -import { EngineDetails } from '../engine/types'; - -import { EnginesLogic } from './engines_logic'; -import { EnginesTable } from './engines_table'; - -describe('EnginesTable', () => { - const onChange = jest.fn(); - const onDeleteEngine = jest.fn(); - - const data = [ - { - name: 'test-engine', - created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', - language: 'English', - isMeta: false, - document_count: 99999, - field_count: 10, - } as EngineDetails, - ]; - const pagination = { - pageIndex: 0, - pageSize: 10, - totalItemCount: 50, - hidePerPageOptions: true, - }; - const props = { - items: data, - loading: false, - pagination, - onChange, - onDeleteEngine, - }; - - const resetMocks = () => { - jest.clearAllMocks(); - setMockValues({ - myRole: { - canManageEngines: false, - }, - }); - }; - - describe('basic table', () => { - let wrapper: ReactWrapper; - let table: ReactWrapper; - - beforeAll(() => { - resetMocks(); - wrapper = mountWithIntl(); - table = wrapper.find(EuiBasicTable); - }); - - it('renders', () => { - expect(table).toHaveLength(1); - expect(table.prop('pagination').totalItemCount).toEqual(50); - - const tableContent = table.text(); - expect(tableContent).toContain('test-engine'); - expect(tableContent).toContain('Jan 1, 1970'); - expect(tableContent).toContain('English'); - expect(tableContent).toContain('99,999'); - expect(tableContent).toContain('10'); - - expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page - }); - - it('contains engine links which send telemetry', () => { - const engineLinks = wrapper.find(EuiLinkTo); - - engineLinks.forEach((link) => { - expect(link.prop('to')).toEqual('/engines/test-engine'); - link.simulate('click'); - - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalledWith({ - action: 'clicked', - metric: 'engine_table_link', - }); - }); - }); - - it('triggers onPaginate', () => { - table.prop('onChange')({ page: { index: 4 } }); - expect(onChange).toHaveBeenCalledWith({ page: { index: 4 } }); - }); - }); - - describe('loading', () => { - it('passes the loading prop', () => { - resetMocks(); - const wrapper = mountWithIntl(); - - expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true); - }); - }); - - describe('noItemsMessage', () => { - it('passes the noItemsMessage prop', () => { - resetMocks(); - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); - }); - }); - - describe('language field', () => { - beforeAll(() => { - resetMocks(); - }); - - it('renders language when available', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).toContain('German'); - }); - - it('renders the language as Universal if no language is set', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).toContain('Universal'); - }); - - it('renders no language text if the engine is a Meta Engine', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).not.toContain('Universal'); - }); - }); - - describe('actions', () => { - it('will hide the action buttons if the user cannot manage/delete engines', () => { - resetMocks(); - const wrapper = shallow(); - const tableRow = wrapper.find(EuiTableRow).first(); - - expect(tableRow.find(EuiIcon)).toHaveLength(0); - }); - - describe('when the user can manage/delete engines', () => { - let wrapper: ReactWrapper; - let tableRow: ReactWrapper; - let actions: ReactWrapper; - - beforeEach(() => { - resetMocks(); - setMockValues({ - myRole: { - canManageEngines: true, - }, - }); - - wrapper = mountWithIntl(); - tableRow = wrapper.find(EuiTableRow).first(); - actions = tableRow.find(EuiIcon); - EnginesLogic.mount(); - }); - - it('renders a manage action', () => { - jest.spyOn(TelemetryLogic.actions, 'sendAppSearchTelemetry'); - jest.spyOn(KibanaLogic.values, 'navigateToUrl'); - actions.at(0).simulate('click'); - - expect(TelemetryLogic.actions.sendAppSearchTelemetry).toHaveBeenCalled(); - expect(KibanaLogic.values.navigateToUrl).toHaveBeenCalledWith('/engines/test-engine'); - }); - - describe('delete action', () => { - it('shows the user a confirm message when the action is clicked', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true); - actions.at(1).simulate('click'); - expect(global.confirm).toHaveBeenCalled(); - }); - - it('clicking the action and confirming deletes the engine', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true); - jest.spyOn(EnginesLogic.actions, 'deleteEngine'); - - actions.at(1).simulate('click'); - - expect(onDeleteEngine).toHaveBeenCalled(); - }); - - it('clicking the action and not confirming does not delete the engine', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(false); - jest.spyOn(EnginesLogic.actions, 'deleteEngine'); - - actions.at(1).simulate('click'); - - expect(onDeleteEngine).toHaveBeenCalledTimes(0); - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx deleted file mode 100644 index 3a65d9c449d6ec..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { ReactNode } from 'react'; - -import { useActions, useValues } from 'kea'; - -import { - EuiBasicTable, - EuiBasicTableColumn, - CriteriaWithPagination, - EuiTableActionsColumnType, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedNumber } from '@kbn/i18n/react'; - -import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../shared/constants'; -import { KibanaLogic } from '../../../shared/kibana'; -import { EuiLinkTo } from '../../../shared/react_router_helpers'; -import { TelemetryLogic } from '../../../shared/telemetry'; -import { AppLogic } from '../../app_logic'; -import { UNIVERSAL_LANGUAGE } from '../../constants'; -import { ENGINE_PATH } from '../../routes'; -import { generateEncodedPath } from '../../utils/encode_path_params'; -import { FormattedDateTime } from '../../utils/formatted_date_time'; -import { EngineDetails } from '../engine/types'; - -interface EnginesTableProps { - items: EngineDetails[]; - loading: boolean; - noItemsMessage?: ReactNode; - pagination: { - pageIndex: number; - pageSize: number; - totalItemCount: number; - hidePerPageOptions: boolean; - }; - onChange(criteria: CriteriaWithPagination): void; - onDeleteEngine(engine: EngineDetails): void; -} - -export const EnginesTable: React.FC = ({ - items, - loading, - noItemsMessage, - pagination, - onChange, - onDeleteEngine, -}) => { - const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const { navigateToUrl } = useValues(KibanaLogic); - const { - myRole: { canManageEngines }, - } = useValues(AppLogic); - - const generateEncodedEnginePath = (engineName: string) => - generateEncodedPath(ENGINE_PATH, { engineName }); - const sendEngineTableLinkClickTelemetry = () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'engine_table_link', - }); - - const columns: Array> = [ - { - field: 'name', - name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { - defaultMessage: 'Name', - }), - render: (name: string) => ( - - {name} - - ), - width: '30%', - truncateText: true, - mobileOptions: { - header: true, - // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error - // @ts-ignore - enlarge: true, - fullWidth: true, - truncateText: false, - }, - }, - { - field: 'created_at', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', - { - defaultMessage: 'Created At', - } - ), - dataType: 'string', - render: (dateString: string) => , - }, - { - field: 'language', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language', - { - defaultMessage: 'Language', - } - ), - dataType: 'string', - render: (language: string, engine: EngineDetails) => - engine.isMeta ? '' : language || UNIVERSAL_LANGUAGE, - }, - { - field: 'document_count', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', - { - defaultMessage: 'Document Count', - } - ), - dataType: 'number', - render: (number: number) => , - truncateText: true, - }, - { - field: 'field_count', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', - { - defaultMessage: 'Field Count', - } - ), - dataType: 'number', - render: (number: number) => , - truncateText: true, - }, - ]; - - const actionsColumn: EuiTableActionsColumnType = { - name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: MANAGE_BUTTON_LABEL, - description: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage.buttonDescription', - { - defaultMessage: 'Manage this engine', - } - ), - type: 'icon', - icon: 'eye', - onClick: (engineDetails) => { - sendEngineTableLinkClickTelemetry(); - navigateToUrl(generateEncodedEnginePath(engineDetails.name)); - }, - }, - { - name: DELETE_BUTTON_LABEL, - description: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.buttonDescription', - { - defaultMessage: 'Delete this engine', - } - ), - type: 'icon', - icon: 'trash', - color: 'danger', - onClick: (engine) => { - if ( - window.confirm( - i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.confirmationPopupMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete "{engineName}" and all of its content?', - values: { - engineName: engine.name, - }, - } - ) - ) - ) { - onDeleteEngine(engine); - } - }, - }, - ], - }; - - if (canManageEngines) { - columns.push(actionsColumn); - } - - return ( - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx index e2adce7dd76876..c76c50094aeddc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx @@ -4,8 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; + import { setMockActions, setMockValues } from '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -37,7 +39,7 @@ describe('RelevanceTuning', () => { resetSearchSettings: jest.fn(), }; - const subject = () => shallow(); + const subject = () => shallow(); beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx index 70adc91dd2b301..ab9bbaa9a1773e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx @@ -23,10 +23,6 @@ import { RelevanceTuningPreview } from './relevance_tuning_preview'; import { RelevanceTuningLogic } from '.'; -interface Props { - engineBreadcrumb: string[]; -} - const EmptyCallout: React.FC = () => { return ( { ); }; -export const RelevanceTuning: React.FC = ({ engineBreadcrumb }) => { +export const RelevanceTuning: React.FC = () => { const { dataLoading, engineHasSchemaFields, unsavedChanges } = useValues(RelevanceTuningLogic); const { initializeRelevanceTuning } = useActions(RelevanceTuningLogic); @@ -95,7 +91,7 @@ export const RelevanceTuning: React.FC = ({ engineBreadcrumb }) => { }; return ( - + {body()} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx index 9ed6e17c2bcd94..6f4333d94919b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx @@ -6,6 +6,7 @@ */ import { setMockActions, setMockValues } from '../../../__mocks__/kea.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -32,7 +33,7 @@ describe('RelevanceTuningLayout', () => { setMockActions(actions); }); - const subject = () => shallow(); + const subject = () => shallow(); const findButtons = (wrapper: ShallowWrapper) => wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx index f29cc12f20a98c..69043d80bd8d00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx @@ -17,16 +17,13 @@ import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; import { RELEVANCE_TUNING_TITLE } from './constants'; import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningLogic } from './relevance_tuning_logic'; -interface Props { - engineBreadcrumb: string[]; -} - -export const RelevanceTuningLayout: React.FC = ({ engineBreadcrumb, children }) => { +export const RelevanceTuningLayout: React.FC = ({ children }) => { const { resetSearchSettings, updateSearchSettings } = useActions(RelevanceTuningLogic); const { engineHasSchemaFields } = useValues(RelevanceTuningLogic); @@ -66,7 +63,7 @@ export const RelevanceTuningLayout: React.FC = ({ engineBreadcrumb, child return ( <> - + {pageHeader()} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index 5365cc0f029f83..a1e1fd920b1398 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues, setMockActions } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -37,7 +37,7 @@ describe('RelevanceTuning', () => { jest.clearAllMocks(); }); - const subject = () => shallow(); + const subject = () => shallow(); const findButtons = (wrapper: ShallowWrapper) => wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; @@ -48,7 +48,7 @@ describe('RelevanceTuning', () => { }); it('initializes result settings data when mounted', () => { - shallow(); + shallow(); expect(actions.initializeResultSettingsData).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index a513d0c1b9f34c..70dbee7425ae81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -18,10 +18,10 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; import { RESULT_SETTINGS_TITLE } from './constants'; import { ResultSettingsTable } from './result_settings_table'; - import { SampleResponse } from './sample_response'; import { ResultSettingsLogic } from '.'; @@ -31,11 +31,7 @@ const CLEAR_BUTTON_LABEL = i18n.translate( { defaultMessage: 'Clear all values' } ); -interface Props { - engineBreadcrumb: string[]; -} - -export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { +export const ResultSettings: React.FC = () => { const { dataLoading } = useValues(ResultSettingsLogic); const { initializeResultSettingsData, @@ -52,7 +48,7 @@ export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { return ( <> - + { const saveSourceParams = jest.fn(); + const setChromeIsVisible = jest.fn(); beforeEach(() => { setMockActions({ saveSourceParams }); + setMockValues({ setChromeIsVisible }); }); it('renders', () => { @@ -32,5 +34,6 @@ describe('SourceAdded', () => { expect(wrapper.find(Loading)).toHaveLength(1); expect(saveSourceParams).toHaveBeenCalled(); + expect(setChromeIsVisible).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx index 7c4e81d8e0755c..5b93b7a426936e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx @@ -9,10 +9,11 @@ import React, { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { Location } from 'history'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { KibanaLogic } from '../../../../shared/kibana'; import { Loading } from '../../../../shared/loading'; import { AddSourceLogic } from './add_source/add_source_logic'; @@ -24,8 +25,12 @@ import { AddSourceLogic } from './add_source/add_source_logic'; */ export const SourceAdded: React.FC = () => { const { search } = useLocation() as Location; + const { setChromeIsVisible } = useValues(KibanaLogic); const { saveSourceParams } = useActions(AddSourceLogic); + // We don't want the personal dashboard to flash the Kibana chrome, so we hide it. + setChromeIsVisible(false); + useEffect(() => { saveSourceParams(search); }, []); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index c653cad5c1c0d7..bc4259fa37889e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -259,4 +259,47 @@ describe('engine routes', () => { }); }); }); + + describe('GET /api/app_search/engines/{name}/source_engines', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/source_engines', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('validates correctly with name', () => { + const request = { params: { name: 'test-engine' } }; + mockRouter.shouldValidate(request); + }); + + it('fails validation without name', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + + it('fails validation with a non-string name', () => { + const request = { params: { name: 1 } }; + mockRouter.shouldThrow(request); + }); + + it('fails validation with missing query params', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:name/source_engines', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 77b055add7d793..f6e9d30dd0adeb 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -95,4 +95,21 @@ export function registerEnginesRoutes({ path: '/as/engines/:name/overview_metrics', }) ); + router.get( + { + path: '/api/app_search/engines/{name}/source_engines', + validate: { + params: schema.object({ + name: schema.string(), + }), + query: schema.object({ + 'page[current]': schema.number(), + 'page[size]': schema.number(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:name/source_engines', + }) + ); } diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index c684c050036124..6d4d107adb796d 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -6,12 +6,12 @@ */ import { keyBy, keys, merge } from 'lodash'; -import type { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; +import type { RequestHandler, SavedObjectsBulkGetObject } from 'src/core/server'; import type { DataStream } from '../../types'; -import { KibanaAssetType, KibanaSavedObjectType } from '../../../common'; +import { KibanaSavedObjectType } from '../../../common'; import type { GetDataStreamsResponse } from '../../../common'; -import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; +import { getPackageSavedObjects } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*'; @@ -78,6 +78,40 @@ export const getListHandler: RequestHandler = async (context, request, response) const packageSavedObjectsByName = keyBy(packageSavedObjects.saved_objects, 'id'); const packageMetadata: any = {}; + // Get dashboard information for all packages + const dashboardIdsByPackageName = packageSavedObjects.saved_objects.reduce< + Record + >((allDashboards, pkgSavedObject) => { + const dashboards: string[] = []; + (pkgSavedObject.attributes?.installed_kibana || []).forEach((o) => { + if (o.type === KibanaSavedObjectType.dashboard) { + dashboards.push(o.id); + } + }); + allDashboards[pkgSavedObject.id] = dashboards; + return allDashboards; + }, {}); + const allDashboardSavedObjects = await context.core.savedObjects.client.bulkGet<{ + title?: string; + }>( + Object.values(dashboardIdsByPackageName).reduce( + (allDashboards, dashboardIds) => { + return allDashboards.concat( + dashboardIds.map((id) => ({ + id, + type: KibanaSavedObjectType.dashboard, + fields: ['title'], + })) + ); + }, + [] + ) + ); + const allDashboardSavedObjectsById = keyBy( + allDashboardSavedObjects.saved_objects, + (dashboardSavedObject) => dashboardSavedObject.id + ); + // Query additional information for each data stream const dataStreamPromises = dataStreamNames.map(async (dataStreamName) => { const dataStream = dataStreams[dataStreamName]; @@ -158,19 +192,23 @@ export const getListHandler: RequestHandler = async (context, request, response) // - and we didn't pick the metadata in an earlier iteration of this map() if (!packageMetadata[pkgName]) { // then pick the dashboards from the package saved object - const dashboards = - pkgSavedObject.attributes?.installed_kibana?.filter( - (o) => o.type === KibanaSavedObjectType.dashboard - ) || []; - // and then pick the human-readable titles from the dashboard saved objects - const enhancedDashboards = await getEnhancedDashboards( - context.core.savedObjects.client, - dashboards - ); + const packageDashboardIds = dashboardIdsByPackageName[pkgName] || []; + const packageDashboards = packageDashboardIds.reduce< + Array<{ id: string; title: string }> + >((dashboards, dashboardId) => { + const dashboard = allDashboardSavedObjectsById[dashboardId]; + if (dashboard) { + dashboards.push({ + id: dashboard.id, + title: dashboard.attributes.title || dashboard.id, + }); + } + return dashboards; + }, []); packageMetadata[pkgName] = { version: pkgSavedObject.attributes?.version || '', - dashboards: enhancedDashboards, + dashboards: packageDashboards, }; } @@ -195,21 +233,3 @@ export const getListHandler: RequestHandler = async (context, request, response) return defaultIngestErrorHandler({ error, response }); } }; - -const getEnhancedDashboards = async ( - savedObjectsClient: SavedObjectsClientContract, - dashboards: any[] -) => { - const dashboardsPromises = dashboards.map(async (db) => { - const dbSavedObject: any = await getKibanaSavedObject( - savedObjectsClient, - KibanaAssetType.dashboard, - db.id - ); - return { - id: db.id, - title: dbSavedObject.attributes?.title || db.id, - }; - }); - return await Promise.all(dashboardsPromises); -}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 98dbd3bd571621..706b2679ed2eb9 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -19,7 +19,6 @@ import type { RegistryPackage, EpmPackageAdditions, } from '../../../../common/types'; -import type { KibanaAssetType } from '../../../types'; import type { Installation, PackageInfo } from '../../../types'; import { IngestManagerError } from '../../../errors'; import { appContextService } from '../../'; @@ -260,11 +259,3 @@ function sortByName(a: { name: string }, b: { name: string }) { return 0; } } - -export async function getKibanaSavedObject( - savedObjectsClient: SavedObjectsClientContract, - type: KibanaAssetType, - id: string -) { - return savedObjectsClient.get(type, id); -} diff --git a/x-pack/plugins/graph/public/components/settings/url_template_form.tsx b/x-pack/plugins/graph/public/components/settings/url_template_form.tsx index 51dc310ababa2c..e89640ef2dbe21 100644 --- a/x-pack/plugins/graph/public/components/settings/url_template_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/url_template_form.tsx @@ -216,15 +216,18 @@ export function UrlTemplateForm(props: UrlTemplateFormProps) { value={currentTemplate.url} onChange={(e) => { setValue('url', e.target.value); - setAutoformatUrl(false); + if ( + (e.nativeEvent as InputEvent)?.inputType !== 'insertFromPaste' || + !isKibanaUrl(e.target.value) + ) { + setAutoformatUrl(false); + } }} onPaste={(e) => { - e.preventDefault(); const pastedUrl = e.clipboardData.getData('text/plain'); if (isKibanaUrl(pastedUrl)) { setAutoformatUrl(true); } - setValue('url', pastedUrl); }} isInvalid={urlPlaceholderMissing || (touched.url && !currentTemplate.url)} /> diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index e47036b82e5941..2c84acc9694960 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -29,6 +29,7 @@ export const POLICY_WITH_MIGRATE_OFF: PolicyFromES = { }, }, warm: { + min_age: '1d', actions: { migrate: { enabled: false }, }, @@ -54,6 +55,7 @@ export const POLICY_WITH_INCLUDE_EXCLUDE: PolicyFromES = { }, }, warm: { + min_age: '10d', actions: { allocate: { include: { @@ -196,6 +198,7 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = ({ }, }, warm: { + min_age: '10d', actions: { my_unfollow_action: {}, set_priority: { @@ -205,6 +208,7 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = ({ }, }, delete: { + min_age: '15d', wait_for_snapshot: { policy: SNAPSHOT_POLICY_NAME, }, 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 12de34b79ee12d..6e4dbd90082a4b 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 @@ -320,10 +320,8 @@ export const setup = async (arg?: { }; /* - * For new we rely on a setTimeout to ensure that error messages have time to populate - * the form object before we look at the form object. See: - * x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx - * for where this logic lives. + * We rely on a setTimeout (dedounce) to display error messages under the form fields. + * This handler runs all the timers so we can assert for errors in our tests. */ const runTimers = () => { act(() => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts index e21793e650683a..ede40521deb97d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts @@ -77,8 +77,10 @@ describe(' searchable snapshots', () => { const repository = 'myRepo'; await actions.hot.setSearchableSnapshot(repository); await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('15'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; @@ -96,8 +98,10 @@ describe(' searchable snapshots', () => { await actions.hot.setSearchableSnapshot('myRepo'); await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('15'); // We update the repository in one phase await actions.frozen.setSearchableSnapshot('changed'); @@ -161,6 +165,7 @@ describe(' searchable snapshots', () => { test('correctly sets snapshot repository default to "found-snapshots"', async () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts index e2d937cf9c8dbb..86cf4ab5a48580 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts @@ -56,7 +56,6 @@ describe(' error indicators', () => { const { actions } = testBed; // 0. No validation issues - expect(actions.hasGlobalErrorCallout()).toBe(false); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -65,7 +64,6 @@ describe(' error indicators', () => { await actions.hot.toggleForceMerge(true); await actions.hot.setForcemergeSegmentsCount('-22'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -75,7 +73,6 @@ describe(' error indicators', () => { await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('-22'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -84,7 +81,6 @@ describe(' error indicators', () => { await actions.cold.enable(true); await actions.cold.setReplicas('-33'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -92,7 +88,6 @@ describe(' error indicators', () => { // 4. Fix validation issue in hot await actions.hot.setForcemergeSegmentsCount('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -100,7 +95,6 @@ describe(' error indicators', () => { // 5. Fix validation issue in warm await actions.warm.setForcemergeSegmentsCount('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -108,13 +102,12 @@ describe(' error indicators', () => { // 6. Fix validation issue in cold await actions.cold.setReplicas('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(false); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); }); - test('global error callout should show if there are any form errors', async () => { + test('global error callout should show, after clicking the "Save" button, if there are any form errors', async () => { const { actions } = testBed; expect(actions.hasGlobalErrorCallout()).toBe(false); @@ -125,6 +118,7 @@ describe(' error indicators', () => { await actions.saveAsNewPolicy(true); await actions.setPolicyName(''); runTimers(); + await actions.savePolicy(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); @@ -136,6 +130,7 @@ describe(' error indicators', () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('7'); // introduce validation error await actions.cold.setSearchableSnapshot(''); runTimers(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts index 52009902ab8021..c0b30efe150c4f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts @@ -81,6 +81,10 @@ describe(' timing validation', () => { test(`${phase}: ${name}`, async () => { const { actions } = testBed; await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].enable(true); + // 1. We first set as dummy value to have a starting min_age value + await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].setMinAgeValue('111'); + // 2. At this point we are sure there will be a change of value and that any validation + // will be displayed under the field. await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].setMinAgeValue(value); runTimers(); @@ -89,4 +93,52 @@ describe(' timing validation', () => { }); }); }); + + test('should validate that min_age is equal or greater than previous phase min_age', async () => { + const { actions, form } = testBed; + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.frozen.enable(true); + await actions.delete.enable(true); + + await actions.warm.setMinAgeValue('10'); + + await actions.cold.setMinAgeValue('9'); + runTimers(); + expect(form.getErrorsMessages('cold-phase')).toEqual([ + 'Must be greater or equal than the warm phase value (10d)', + ]); + + await actions.frozen.setMinAgeValue('8'); + runTimers(); + expect(form.getErrorsMessages('frozen-phase')).toEqual([ + 'Must be greater or equal than the cold phase value (9d)', + ]); + + await actions.delete.setMinAgeValue('7'); + runTimers(); + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([ + 'Must be greater or equal than the frozen phase value (8d)', + ]); + + // Disable the warm phase + await actions.warm.enable(false); + + // No more error for the cold phase + expect(form.getErrorsMessages('cold-phase')).toEqual([]); + + // Change to smaller unit for cold phase + await actions.cold.setMinAgeUnits('h'); + + // No more error for the frozen phase... + expect(form.getErrorsMessages('frozen-phase')).toEqual([]); + // ...but the delete phase has still the error + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([ + 'Must be greater or equal than the frozen phase value (8d)', + ]); + + await actions.delete.setMinAgeValue('9'); + // No more error for the delete phase + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([]); + }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts index aa176fe3b188fb..7a0571e4a7cb2b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts @@ -87,7 +87,7 @@ describe(' serialization', () => { unknown_setting: true, }, }, - min_age: '0d', + min_age: '10d', }, }, }); @@ -264,6 +264,7 @@ describe(' serialization', () => { test('default values', async () => { const { actions } = testBed; await actions.warm.enable(true); + await actions.warm.setMinAgeValue('11'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const warmPhase = JSON.parse(JSON.parse(latestRequest.requestBody).body).phases.warm; @@ -274,7 +275,7 @@ describe(' serialization', () => { "priority": 50, }, }, - "min_age": "0d", + "min_age": "11d", } `); }); @@ -282,6 +283,7 @@ describe(' serialization', () => { test('setting all values', async () => { const { actions } = testBed; await actions.warm.enable(true); + await actions.warm.setMinAgeValue('11'); await actions.warm.setDataAllocation('node_attrs'); await actions.warm.setSelectedNodeAttribute('test:123'); await actions.warm.setReplicas('123'); @@ -329,7 +331,7 @@ describe(' serialization', () => { "number_of_shards": 123, }, }, - "min_age": "0d", + "min_age": "11d", }, }, } @@ -401,6 +403,7 @@ describe(' serialization', () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('11'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); @@ -411,7 +414,7 @@ describe(' serialization', () => { "priority": 0, }, }, - "min_age": "0d", + "min_age": "11d", } `); }); @@ -471,6 +474,7 @@ describe(' serialization', () => { test('setting searchable snapshot', async () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.setSearchableSnapshot('my-repo'); await actions.savePolicy(); const latestRequest2 = server.requests[server.requests.length - 1]; @@ -485,6 +489,7 @@ describe(' serialization', () => { test('default value', async () => { const { actions } = testBed; await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('13'); await actions.frozen.setSearchableSnapshot('myRepo'); await actions.savePolicy(); @@ -492,7 +497,7 @@ describe(' serialization', () => { const latestRequest = server.requests[server.requests.length - 1]; const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); expect(entirePolicy.phases.frozen).toEqual({ - min_age: '0d', + min_age: '13d', actions: { searchable_snapshot: { snapshot_repository: 'myRepo' }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx index b72ec1df2f26b3..478d1af69f81ce 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx @@ -25,9 +25,10 @@ const i18nTexts = { export const FormErrorsCallout: FunctionComponent = () => { const { errors: { hasErrors }, + isFormSubmitted, } = useFormErrorsContext(); - if (!hasErrors) { + if (!isFormSubmitted || !hasErrors) { return null; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 3fe2f08cb4066e..136a37140cca7c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -6,8 +6,9 @@ */ import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { get } from 'lodash'; import { EuiFieldNumber, @@ -20,10 +21,9 @@ import { EuiIconTip, } from '@elastic/eui'; -import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; - -import { UseField, useConfiguration } from '../../../../form'; - +import { getFieldValidityAndErrorMessage, useFormData } from '../../../../../../../shared_imports'; +import { UseField, useConfiguration, useGlobalFields } from '../../../../form'; +import { getPhaseMinAgeInMilliseconds } from '../../../../lib'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; type PhaseWithMinAgeAction = 'warm' | 'cold' | 'delete'; @@ -81,9 +81,43 @@ interface Props { } export const MinAgeField: FunctionComponent = ({ phase }): React.ReactElement => { + const minAgeValuePath = `phases.${phase}.min_age`; + const minAgeUnitPath = `_meta.${phase}.minAgeUnit`; + const { isUsingRollover } = useConfiguration(); + const globalFields = useGlobalFields(); + + const { setValue: setMillisecondValue } = globalFields[ + `${phase}MinAgeMilliSeconds` as 'coldMinAgeMilliSeconds' + ]; + const [formData] = useFormData({ watch: [minAgeValuePath, minAgeUnitPath] }); + const minAgeValue = get(formData, minAgeValuePath); + const minAgeUnit = get(formData, minAgeUnitPath); + + useEffect(() => { + // Whenever the min_age value of the field OR the min_age unit + // changes, we update the corresponding millisecond global field for the phase + if (minAgeValue === undefined) { + return; + } + + const milliseconds = + minAgeValue.trim() === '' ? -1 : getPhaseMinAgeInMilliseconds(minAgeValue, minAgeUnit); + + setMillisecondValue(milliseconds); + }, [minAgeValue, minAgeUnit, setMillisecondValue]); + + useEffect(() => { + return () => { + // When unmounting (meaning we have disabled the phase), we remove + // the millisecond value so the next time we enable the phase it will + // be updated and trigger the validation + setMillisecondValue(-1); + }; + }, [setMillisecondValue]); + return ( - + {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -118,7 +152,7 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle /> - + {(unitField) => { const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( unitField diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index af571d16ca8c5e..356a5b4561d0a6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -46,20 +46,24 @@ export const createDeserializer = (isCloudEnabled: boolean) => ( bestCompression: warm?.actions?.forcemerge?.index_codec === 'best_compression', dataTierAllocationType: determineDataTierAllocationType(warm?.actions), readonlyEnabled: Boolean(warm?.actions?.readonly), + minAgeToMilliSeconds: -1, }, cold: { enabled: Boolean(cold), dataTierAllocationType: determineDataTierAllocationType(cold?.actions), freezeEnabled: Boolean(cold?.actions?.freeze), readonlyEnabled: Boolean(cold?.actions?.readonly), + minAgeToMilliSeconds: -1, }, frozen: { enabled: Boolean(frozen), dataTierAllocationType: determineDataTierAllocationType(frozen?.actions), freezeEnabled: Boolean(frozen?.actions?.freeze), + minAgeToMilliSeconds: -1, }, delete: { enabled: Boolean(deletePhase), + minAgeToMilliSeconds: -1, }, searchableSnapshot: { repository: defaultRepository, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx index b4aab0ffdea600..70199e08aa3084 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx @@ -38,6 +38,7 @@ interface ContextValue { errors: Errors; addError(phase: PhasesAndOther, fieldPath: string, errorMessages: string[]): void; clearError(phase: PhasesAndOther, fieldPath: string): void; + isFormSubmitted: boolean; } const FormErrorsContext = createContext(null as any); @@ -56,7 +57,7 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { const [errors, setErrors] = useState(createEmptyErrors); const form = useFormContext(); - const { getErrors: getFormErrors } = form; + const { getErrors: getFormErrors, isSubmitted } = form; const addError: ContextValue['addError'] = useCallback( (phase, fieldPath, errorMessages) => { @@ -83,9 +84,9 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { } = previousErrors; const nextHasErrors = - Object.keys(restOfPhaseErrors).length === 0 && + Object.keys(restOfPhaseErrors).length > 0 || Object.values(otherPhases).some((phaseErrors) => { - return !!Object.keys(phaseErrors).length; + return Object.keys(phaseErrors).length > 0; }); return { @@ -107,6 +108,7 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { errors, addError, clearError, + isFormSubmitted: isSubmitted, }} > {children} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx index 30a00390a18cca..94b804c1ce5324 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx @@ -14,6 +14,10 @@ import { UseMultiFields, FieldHook, FieldConfig } from '../../../../shared_impor interface GlobalFieldsTypes { deleteEnabled: boolean; searchableSnapshotRepo: string; + warmMinAgeMilliSeconds: number; + coldMinAgeMilliSeconds: number; + frozenMinAgeMilliSeconds: number; + deleteMinAgeMilliSeconds: number; } type GlobalFields = { @@ -32,6 +36,18 @@ export const globalFields: Record< searchableSnapshotRepo: { path: '_meta.searchableSnapshot.repository', }, + warmMinAgeMilliSeconds: { + path: '_meta.warm.minAgeToMilliSeconds', + }, + coldMinAgeMilliSeconds: { + path: '_meta.cold.minAgeToMilliSeconds', + }, + frozenMinAgeMilliSeconds: { + path: '_meta.frozen.minAgeToMilliSeconds', + }, + deleteMinAgeMilliSeconds: { + path: '_meta.delete.minAgeToMilliSeconds', + }, }; export const GlobalFieldsProvider: FunctionComponent = ({ children }) => { 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 index ce7b36d69a32e7..93af58644cc060 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 @@ -10,12 +10,14 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultIndexPriority } from '../../../constants'; import { ROLLOVER_FORM_PATHS, CLOUD_DEFAULT_REPO } from '../constants'; +import { MinAgePhase } from '../types'; import { i18nTexts } from '../i18n_texts'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, rolloverThresholdsValidator, integerValidator, + minAgeGreaterThanPreviousPhase, } from './validations'; const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); @@ -117,8 +119,11 @@ const getPriorityField = (phase: 'hot' | 'warm' | 'cold' | 'frozen') => ({ serializer: serializers.stringToNumber, }); -const getMinAgeField = (defaultValue: string = '0') => ({ +const getMinAgeField = (phase: MinAgePhase, defaultValue?: string) => ({ defaultValue, + // By passing an empty array we make sure to *not* trigger the validation when the field value changes. + // The validation will be triggered when the millisecond variant (in the _meta) is updated (in sync) + fieldsToValidateOnChange: [], validations: [ { validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), @@ -129,8 +134,12 @@ const getMinAgeField = (defaultValue: string = '0') => ({ { validator: integerValidator, }, + { + validator: minAgeGreaterThanPreviousPhase(phase), + }, ], }); + export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ _meta: { hot: { @@ -173,6 +182,15 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: [ + 'phases.warm.min_age', + 'phases.cold.min_age', + 'phases.frozen.min_age', + 'phases.delete.min_age', + ], + }, bestCompression: { label: i18nTexts.editPolicy.bestCompressionFieldLabel, }, @@ -208,6 +226,14 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: [ + 'phases.cold.min_age', + 'phases.frozen.min_age', + 'phases.delete.min_age', + ], + }, dataTierAllocationType: { label: i18nTexts.editPolicy.allocationTypeOptionsFieldLabel, }, @@ -232,6 +258,10 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: ['phases.frozen.min_age', 'phases.delete.min_age'], + }, dataTierAllocationType: { label: i18nTexts.editPolicy.allocationTypeOptionsFieldLabel, }, @@ -250,6 +280,10 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: ['phases.delete.min_age'], + }, }, searchableSnapshot: { repository: { @@ -324,7 +358,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, warm: { - min_age: getMinAgeField(), + min_age: getMinAgeField('warm'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -341,7 +375,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, cold: { - min_age: getMinAgeField(), + min_age: getMinAgeField('cold'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -353,7 +387,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, frozen: { - min_age: getMinAgeField(), + min_age: getMinAgeField('frozen'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -365,7 +399,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, delete: { - min_age: getMinAgeField('365'), + min_age: getMinAgeField('delete', '365'), actions: { wait_for_snapshot: { 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 index ce85913d5db749..70a58ad1441926 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,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; import { fieldValidators, ValidationFunc, ValidationConfig } from '../../../../shared_imports'; @@ -11,7 +12,7 @@ import { ROLLOVER_FORM_PATHS } from '../constants'; import { i18nTexts } from '../i18n_texts'; import { PolicyFromES } from '../../../../../common/types'; -import { FormInternal } from '../types'; +import { FormInternal, MinAgePhase } from '../types'; const { numberGreaterThanField, containsCharsField, emptyField, startsWithField } = fieldValidators; @@ -149,3 +150,117 @@ export const createPolicyNameValidations = ({ }, ]; }; + +/** + * This validator guarantees that the user does not specify a min_age + * value smaller that the min_age of a previous phase. + * For example, the user can't define '5 days' for cold phase if the + * warm phase is set to '10 days'. + */ +export const minAgeGreaterThanPreviousPhase = (phase: MinAgePhase) => ({ + formData, +}: { + formData: Record; +}) => { + if (phase === 'warm') { + return; + } + + const getValueFor = (_phase: MinAgePhase) => { + const milli = formData[`_meta.${_phase}.minAgeToMilliSeconds`]; + + const esFormat = + milli >= 0 + ? formData[`phases.${_phase}.min_age`] + formData[`_meta.${_phase}.minAgeUnit`] + : undefined; + + return { + milli, + esFormat, + }; + }; + + const minAgeValues = { + warm: getValueFor('warm'), + cold: getValueFor('cold'), + frozen: getValueFor('frozen'), + delete: getValueFor('delete'), + }; + + const i18nErrors = { + greaterThanWarmPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanWarmPhaseError', + { + defaultMessage: 'Must be greater or equal than the warm phase value ({value})', + values: { + value: minAgeValues.warm.esFormat, + }, + } + ), + greaterThanColdPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanColdPhaseError', + { + defaultMessage: 'Must be greater or equal than the cold phase value ({value})', + values: { + value: minAgeValues.cold.esFormat, + }, + } + ), + greaterThanFrozenPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanFrozenPhaseError', + { + defaultMessage: 'Must be greater or equal than the frozen phase value ({value})', + values: { + value: minAgeValues.frozen.esFormat, + }, + } + ), + }; + + if (phase === 'cold') { + if (minAgeValues.warm.milli >= 0 && minAgeValues.cold.milli < minAgeValues.warm.milli) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + return; + } + + if (phase === 'frozen') { + if (minAgeValues.cold.milli >= 0 && minAgeValues.frozen.milli < minAgeValues.cold.milli) { + return { + message: i18nErrors.greaterThanColdPhase, + }; + } else if ( + minAgeValues.warm.milli >= 0 && + minAgeValues.frozen.milli < minAgeValues.warm.milli + ) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + return; + } + + if (phase === 'delete') { + if (minAgeValues.frozen.milli >= 0 && minAgeValues.delete.milli < minAgeValues.frozen.milli) { + return { + message: i18nErrors.greaterThanFrozenPhase, + }; + } else if ( + minAgeValues.cold.milli >= 0 && + minAgeValues.delete.milli < minAgeValues.cold.milli + ) { + return { + message: i18nErrors.greaterThanColdPhase, + }; + } else if ( + minAgeValues.warm.milli >= 0 && + minAgeValues.delete.milli < minAgeValues.warm.milli + ) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index 5d71bc057966e2..9d55f542db4c47 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -24,12 +24,10 @@ import moment from 'moment'; import { splitSizeAndUnits } from '../../../lib/policies'; -import { FormInternal } from '../types'; +import { FormInternal, MinAgePhase } from '../types'; /* -===- Private functions and types -===- */ -type MinAgePhase = 'warm' | 'cold' | 'frozen' | 'delete'; - type Phase = 'hot' | MinAgePhase; const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'frozen', 'delete']; @@ -44,9 +42,9 @@ const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math * for all date math values. ILM policies also support "micros" and "nanos". */ -const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { +export const getPhaseMinAgeInMilliseconds = (size: string, units: string): number => { let milliseconds: number; - const { units, size } = splitSizeAndUnits(phase.min_age); + if (units === 'micros') { milliseconds = parseInt(size, 10) / 1e3; } else if (units === 'nanos') { @@ -126,7 +124,10 @@ export const calculateRelativeFromAbsoluteMilliseconds = ( // If we have a next phase, calculate the timing between this phase and the next if (nextPhase && inputs[nextPhase]?.min_age) { - nextPhaseMinAge = getPhaseMinAgeInMilliseconds(inputs[nextPhase] as { min_age: string }); + const { units, size } = splitSizeAndUnits( + (inputs[nextPhase] as { min_age: string }).min_age + ); + nextPhaseMinAge = getPhaseMinAgeInMilliseconds(size, units); } return { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts index 19d87532f2bfe9..607c62cd3ce8be 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts @@ -8,6 +8,7 @@ export { calculateRelativeFromAbsoluteMilliseconds, formDataToAbsoluteTimings, + getPhaseMinAgeInMilliseconds, AbsoluteTimings, PhaseAgeInMilliseconds, RelativePhaseTimingInMs, 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 5cc631c5d95c0f..688d2ecfaa4a2c 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 @@ -15,8 +15,11 @@ export interface DataAllocationMetaFields { export interface MinAgeField { minAgeUnit?: string; + minAgeToMilliSeconds: number; } +export type MinAgePhase = 'warm' | 'cold' | 'frozen' | 'delete'; + export interface ForcemergeFields { bestCompression: boolean; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts index 5c839262b62ed2..185e521e4a5b84 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts @@ -7,14 +7,11 @@ import { DocLinksStart } from 'src/core/public'; -// eslint-disable-next-line @typescript-eslint/naming-convention -export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksStart) => { - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - +export const getDocumentation = ({ links }: DocLinksStart) => { + const esDocsBase = links.elasticsearch.docsBase; return { esDocsBase, - componentTemplates: `${esDocsBase}/indices-component-template.html`, - componentTemplatesMetadata: `${esDocsBase}/indices-component-template.html#component-templates-metadata`, + componentTemplates: links.apis.putComponentTemplate, + componentTemplatesMetadata: links.apis.putComponentTemplateMetadata, }; }; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index c81c71a32e7e23..3d6c6edf986e8e 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -10,15 +10,98 @@ import { DataType } from '../components/mappings_editor/types'; import { TYPE_DEFINITION } from '../components/mappings_editor/constants'; class DocumentationService { + private dataStreams: string = ''; private esDocsBase: string = ''; - private kibanaDocsBase: string = ''; - + private indexManagement: string = ''; + private indexSettings: string = ''; + private indexTemplates: string = ''; + private indexV1: string = ''; + private mapping: string = ''; + private mappingAnalyzer: string = ''; + private mappingCoerce: string = ''; + private mappingCopyTo: string = ''; + private mappingDocValues: string = ''; + private mappingDynamic: string = ''; + private mappingDynamicFields: string = ''; + private mappingDynamicTemplates: string = ''; + private mappingEagerGlobalOrdinals: string = ''; + private mappingEnabled: string = ''; + private mappingFieldData: string = ''; + private mappingFieldDataFilter: string = ''; + private mappingFieldDataTypes: string = ''; + private mappingFieldDataEnable: string = ''; + private mappingFormat: string = ''; + private mappingIgnoreAbove: string = ''; + private mappingIgnoreMalformed: string = ''; + private mappingIndex: string = ''; + private mappingIndexOptions: string = ''; + private mappingIndexPhrases: string = ''; + private mappingIndexPrefixes: string = ''; + private mappingJoinFieldsPerformance: string = ''; + private mappingMeta: string = ''; + private mappingMetaFields: string = ''; + private mappingNormalizer: string = ''; + private mappingNorms: string = ''; + private mappingNullValue: string = ''; + private mappingParameters: string = ''; + private mappingPositionIncrementGap: string = ''; + private mappingRankFeatureFields: string = ''; + private mappingRouting: string = ''; + private mappingSimilarity: string = ''; + private mappingSourceFields: string = ''; + private mappingSourceFieldsDisable: string = ''; + private mappingStore: string = ''; + private mappingTermVector: string = ''; + private mappingTypesRemoval: string = ''; + private percolate: string = ''; + private runtimeFields: string = ''; public setup(docLinks: DocLinksStart): void { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - - this.esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - this.kibanaDocsBase = `${docsBase}/kibana/${DOC_LINK_VERSION}`; + const { links } = docLinks; + this.dataStreams = links.elasticsearch.dataStreams; + this.esDocsBase = links.elasticsearch.docsBase; + this.indexManagement = links.management.indexManagement; + this.indexSettings = links.elasticsearch.indexSettings; + this.indexTemplates = links.elasticsearch.indexTemplates; + this.indexV1 = links.apis.putIndexTemplateV1; + this.mapping = links.elasticsearch.mapping; + this.mappingAnalyzer = links.elasticsearch.mappingAnalyzer; + this.mappingCoerce = links.elasticsearch.mappingCoerce; + this.mappingCopyTo = links.elasticsearch.mappingCopyTo; + this.mappingDocValues = links.elasticsearch.mappingDocValues; + this.mappingDynamic = links.elasticsearch.mappingDynamic; + this.mappingDynamicFields = links.elasticsearch.mappingDynamicFields; + this.mappingDynamicTemplates = links.elasticsearch.mappingDynamicTemplates; + this.mappingEagerGlobalOrdinals = links.elasticsearch.mappingEagerGlobalOrdinals; + this.mappingEnabled = links.elasticsearch.mappingEnabled; + this.mappingFieldData = links.elasticsearch.mappingFieldData; + this.mappingFieldDataTypes = links.elasticsearch.mappingFieldDataTypes; + this.mappingFieldDataEnable = links.elasticsearch.mappingFieldDataEnable; + this.mappingFieldDataFilter = links.elasticsearch.mappingFieldDataFilter; + this.mappingFormat = links.elasticsearch.mappingFormat; + this.mappingIgnoreAbove = links.elasticsearch.mappingIgnoreAbove; + this.mappingIgnoreMalformed = links.elasticsearch.mappingIgnoreMalformed; + this.mappingIndex = links.elasticsearch.mappingIndex; + this.mappingIndexOptions = links.elasticsearch.mappingIndexOptions; + this.mappingIndexPhrases = links.elasticsearch.mappingIndexPhrases; + this.mappingIndexPrefixes = links.elasticsearch.mappingIndexPrefixes; + this.mappingJoinFieldsPerformance = links.elasticsearch.mappingJoinFieldsPerformance; + this.mappingMeta = links.elasticsearch.mappingMeta; + this.mappingMetaFields = links.elasticsearch.mappingMetaFields; + this.mappingNormalizer = links.elasticsearch.mappingNormalizer; + this.mappingNorms = links.elasticsearch.mappingNorms; + this.mappingNullValue = links.elasticsearch.mappingNullValue; + this.mappingParameters = links.elasticsearch.mappingParameters; + this.mappingPositionIncrementGap = links.elasticsearch.mappingPositionIncrementGap; + this.mappingRankFeatureFields = links.elasticsearch.mappingRankFeatureFields; + this.mappingRouting = links.elasticsearch.mappingRouting; + this.mappingSimilarity = links.elasticsearch.mappingSimilarity; + this.mappingSourceFields = links.elasticsearch.mappingSourceFields; + this.mappingSourceFieldsDisable = links.elasticsearch.mappingSourceFieldsDisable; + this.mappingStore = links.elasticsearch.mappingStore; + this.mappingTermVector = links.elasticsearch.mappingTermVector; + this.mappingTypesRemoval = links.elasticsearch.mappingTypesRemoval; + this.percolate = links.query.percolate; + this.runtimeFields = links.runtimeFields.overview; } public getEsDocsBase() { @@ -26,29 +109,27 @@ class DocumentationService { } public getSettingsDocumentationLink() { - return `${this.esDocsBase}/index-modules.html#index-modules-settings`; + return this.indexSettings; } public getMappingDocumentationLink() { - return `${this.esDocsBase}/mapping.html`; + return this.mapping; } public getRoutingLink() { - return `${this.esDocsBase}/mapping-routing-field.html`; + return this.mappingRouting; } public getDataStreamsDocumentationLink() { - return `${this.esDocsBase}/data-streams.html`; + return this.dataStreams; } public getTemplatesDocumentationLink(isLegacy = false) { - return isLegacy - ? `${this.esDocsBase}/indices-templates-v1.html` - : `${this.esDocsBase}/indices-templates.html`; + return isLegacy ? this.indexV1 : this.indexTemplates; } public getIdxMgmtDocumentationLink() { - return `${this.kibanaDocsBase}/managing-indices.html`; + return this.indexManagement; } public getTypeDocLink = (type: DataType, docType = 'main'): string | undefined => { @@ -63,157 +144,154 @@ class DocumentationService { } return `${this.esDocsBase}${typeDefinition.documentation[docType]}`; }; - public getMappingTypesLink() { - return `${this.esDocsBase}/mapping-types.html`; + return this.mappingFieldDataTypes; } - public getDynamicMappingLink() { - return `${this.esDocsBase}/dynamic-field-mapping.html`; + return this.mappingDynamicFields; } - public getPercolatorQueryLink() { - return `${this.esDocsBase}/query-dsl-percolate-query.html`; + return this.percolate; } public getRankFeatureQueryLink() { - return `${this.esDocsBase}/rank-feature.html`; + return this.mappingRankFeatureFields; } public getMetaFieldLink() { - return `${this.esDocsBase}/mapping-meta-field.html`; + return this.mappingMetaFields; } public getDynamicTemplatesLink() { - return `${this.esDocsBase}/dynamic-templates.html`; + return this.mappingDynamicTemplates; } public getMappingSourceFieldLink() { - return `${this.esDocsBase}/mapping-source-field.html`; + return this.mappingSourceFields; } public getDisablingMappingSourceFieldLink() { - return `${this.esDocsBase}/mapping-source-field.html#disable-source-field`; + return this.mappingSourceFieldsDisable; } public getNullValueLink() { - return `${this.esDocsBase}/null-value.html`; + return this.mappingNullValue; } public getTermVectorLink() { - return `${this.esDocsBase}/term-vector.html`; + return this.mappingTermVector; } public getStoreLink() { - return `${this.esDocsBase}/mapping-store.html`; + return this.mappingStore; } public getSimilarityLink() { - return `${this.esDocsBase}/similarity.html`; + return this.mappingSimilarity; } public getNormsLink() { - return `${this.esDocsBase}/norms.html`; + return this.mappingNorms; } public getIndexLink() { - return `${this.esDocsBase}/mapping-index.html`; + return this.mappingIndex; } public getIgnoreMalformedLink() { - return `${this.esDocsBase}/ignore-malformed.html`; + return this.mappingIgnoreMalformed; } public getMetaLink() { - return `${this.esDocsBase}/mapping-field-meta.html`; + return this.mappingMeta; } public getFormatLink() { - return `${this.esDocsBase}/mapping-date-format.html`; + return this.mappingFormat; } public getEagerGlobalOrdinalsLink() { - return `${this.esDocsBase}/eager-global-ordinals.html`; + return this.mappingEagerGlobalOrdinals; } public getDocValuesLink() { - return `${this.esDocsBase}/doc-values.html`; + return this.mappingDocValues; } public getCopyToLink() { - return `${this.esDocsBase}/copy-to.html`; + return this.mappingCopyTo; } public getCoerceLink() { - return `${this.esDocsBase}/coerce.html`; + return this.mappingCoerce; } public getBoostLink() { - return `${this.esDocsBase}/mapping-boost.html`; + return this.mappingParameters; } public getNormalizerLink() { - return `${this.esDocsBase}/normalizer.html`; + return this.mappingNormalizer; } public getIgnoreAboveLink() { - return `${this.esDocsBase}/ignore-above.html`; + return this.mappingIgnoreAbove; } public getFielddataLink() { - return `${this.esDocsBase}/fielddata.html`; + return this.mappingFieldData; } public getFielddataFrequencyLink() { - return `${this.esDocsBase}/fielddata.html#field-data-filtering`; + return this.mappingFieldDataFilter; } public getEnablingFielddataLink() { - return `${this.esDocsBase}/fielddata.html#before-enabling-fielddata`; + return this.mappingFieldDataEnable; } public getIndexPhrasesLink() { - return `${this.esDocsBase}/index-phrases.html`; + return this.mappingIndexPhrases; } public getIndexPrefixesLink() { - return `${this.esDocsBase}/index-prefixes.html`; + return this.mappingIndexPrefixes; } public getPositionIncrementGapLink() { - return `${this.esDocsBase}/position-increment-gap.html`; + return this.mappingPositionIncrementGap; } public getAnalyzerLink() { - return `${this.esDocsBase}/analyzer.html`; + return this.mappingAnalyzer; } public getDateFormatLink() { - return `${this.esDocsBase}/mapping-date-format.html`; + return this.mappingFormat; } public getIndexOptionsLink() { - return `${this.esDocsBase}/index-options.html`; + return this.mappingIndexOptions; } public getAlternativeToMappingTypesLink() { - return `${this.esDocsBase}/removal-of-types.html#_alternatives_to_mapping_types`; + return this.mappingTypesRemoval; } public getJoinMultiLevelsPerformanceLink() { - return `${this.esDocsBase}/parent-join.html#_parent_join_and_performance`; + return this.mappingJoinFieldsPerformance; } public getDynamicLink() { - return `${this.esDocsBase}/dynamic.html`; + return this.mappingDynamic; } public getEnabledLink() { - return `${this.esDocsBase}/enabled.html`; + return this.mappingEnabled; } public getRuntimeFields() { - return `${this.esDocsBase}/runtime.html`; + return this.runtimeFields; } public getWellKnownTextLink() { diff --git a/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx index 43dcefae66ad51..6beb565be098c1 100644 --- a/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx +++ b/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx @@ -26,7 +26,7 @@ describe('debouncedComponent', () => { component.setProps({ title: 'yall' }); expect(component.text()).toEqual('there'); await act(async () => { - await new Promise((r) => setTimeout(r, 1)); + await new Promise((r) => setTimeout(r, 10)); }); expect(component.text()).toEqual('yall'); }); diff --git a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts deleted file mode 100644 index 1516ca9128893b..00000000000000 --- a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { RecursivePartial } from '@elastic/eui/src/components/common'; - -import { EuiTheme } from '../../../../../../src/plugins/kibana_react/common'; - -export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => - partialTheme as EuiTheme; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx index 8272ca9683a4f0..74ec0759b057ef 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx @@ -5,26 +5,17 @@ * 2.0. */ -import { Story, addDecorator } from '@storybook/react'; +import { Story } from '@storybook/react'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { AndOrBadge, AndOrBadgeProps } from '.'; const sampleText = 'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); - -addDecorator((storyFn) => {storyFn()}); - export default { argTypes: { includeAntennas: { @@ -58,6 +49,13 @@ export default { }, }, component: AndOrBadge, + decorators: [ + (DecoratorStory: React.ComponentClass): React.ReactNode => ( + + + + ), + ], title: 'AndOrBadge', }; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx index 47282d061a65de..26aa41549e61b3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { AndOrBadge } from './'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('AndOrBadge', () => { test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -30,9 +27,9 @@ describe('AndOrBadge', () => { test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); @@ -42,9 +39,9 @@ describe('AndOrBadge', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -52,9 +49,9 @@ describe('AndOrBadge', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx index 472345b9c9f191..dd5ed999dadcd3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { RoundedBadgeAntenna } from './rounded_badge_antenna'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('RoundedBadgeAntenna', () => { test('it renders top and bottom antenna bars', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -30,9 +27,9 @@ describe('RoundedBadgeAntenna', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -40,9 +37,9 @@ describe('RoundedBadgeAntenna', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx index dc773e222776b9..4a1471d9a3e5d1 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { BuilderAndBadgeComponent } from './and_badge'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryFirstRowAndBadge for very first exception item in builder', () => { const wrapper = mount( - + - + ); expect( @@ -30,9 +27,9 @@ describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - + - + ); expect( @@ -42,9 +39,9 @@ describe('BuilderAndBadgeComponent', () => { test('it renders regular "and" badge if exception item is not the first one and includes more than one entry', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx index 5199ead78ca0a0..8eaba9e82d7247 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx @@ -13,16 +13,14 @@ import { Story, addDecorator } from '@storybook/react'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { HttpStart } from 'kibana/public'; import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { fields, getField, } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { getEntryExistsMock } from '../../../../common/schemas/types/entry_exists.mock'; @@ -35,10 +33,6 @@ import { OnChangeProps, } from './exception_items_renderer'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); const mockHttpService: HttpStart = ({ addLoadingCountSource: (): void => {}, anonymousPaths: { @@ -76,7 +70,7 @@ const mockAutocompleteService = ({ }), } as unknown) as AutocompleteStart; -addDecorator((storyFn) => {storyFn()}); +addDecorator((storyFn) => {storyFn()}); export default { argTypes: { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx index 8408fb7a6a4f1b..5b3730a6deb933 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx @@ -5,24 +5,18 @@ * 2.0. */ -import { Story, addDecorator } from '@storybook/react'; +import { Story } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { HttpStart } from 'kibana/public'; import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { BuilderEntryItem, EntryItemProps } from './entry_renderer'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); const mockAutocompleteService = ({ getValueSuggestions: () => new Promise((resolve) => { @@ -59,8 +53,6 @@ const mockAutocompleteService = ({ }), } as unknown) as AutocompleteStart; -addDecorator((storyFn) => {storyFn()}); - export default { argTypes: { allowLargeValueLists: { @@ -163,6 +155,13 @@ export default { }, }, component: BuilderEntryItem, + decorators: [ + (DecoratorStory: React.ComponentClass): React.ReactNode => ( + + + + ), + ], title: 'BuilderEntryItem', }; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index 0fd886bdc742a4..b896f2a44f67b3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -6,24 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { BuilderExceptionListItemComponent } from './exception_item_renderer'; -const mockTheme = getMockTheme({ - eui: { - euiColorLightShade: '#ece', - }, -}); const mockKibanaHttpService = coreMock.createStart().http; const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); @@ -41,7 +35,7 @@ describe('BuilderExceptionListItemComponent', () => { entries: [getEntryMatchMock(), getEntryMatchMock()], }; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( @@ -72,7 +66,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeTruthy(); @@ -101,7 +95,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( @@ -132,7 +126,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx index b8ec8dc354bf84..a236b102eabf7b 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx @@ -6,28 +6,22 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { ReactWrapper, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; import { coreMock } from 'src/core/public/mocks'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { fields, getField, } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { getEmptyValue } from '../../../common/empty_value'; import { ExceptionBuilderComponent } from './exception_items_renderer'; -const mockTheme = getMockTheme({ - eui: { - euiColorLightShade: '#ece', - }, -}); const mockKibanaHttpService = coreMock.createStart().http; const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); @@ -44,7 +38,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if no "exceptionListItems" are passed in', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( @@ -83,7 +77,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "exceptionListItems" that are passed in', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( 1 @@ -128,7 +122,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "or", "and" and "add nested button" enabled', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect( @@ -165,7 +159,7 @@ describe('ExceptionBuilderComponent', () => { test('it adds an entry when "and" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( @@ -222,7 +216,7 @@ describe('ExceptionBuilderComponent', () => { test('it adds an exception item when "or" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionEntriesContainer"]')).toHaveLength( @@ -283,7 +277,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if user deletes last remaining entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('[data-test-subj="exceptionBuilderEntryField"]').at(0).text()).toEqual( @@ -338,7 +332,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "and" badge if at least one exception item includes more than one entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect( @@ -374,7 +368,7 @@ describe('ExceptionBuilderComponent', () => { test('it does not display "and" badge if none of the exception items include more than one entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); wrapper.find('[data-test-subj="exceptionsOrButton"] button').simulate('click'); @@ -413,7 +407,7 @@ describe('ExceptionBuilderComponent', () => { describe('nested entry', () => { test('it adds a nested entry when "add nested entry" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click'); diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index bf180c514c56fa..569f7e17896f2b 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -125,8 +125,7 @@ async function isFieldGeoShape( if (!indexPattern) { return false; } - const fieldsForIndexPattern = await indexPatternsService.getFieldsForIndexPattern(indexPattern); - return fieldsForIndexPattern.some( + return indexPattern.fields.some( (fieldDescriptor: IFieldType) => fieldDescriptor.name && fieldDescriptor.name === geoField! ); } @@ -192,13 +191,9 @@ async function filterIndexPatternsByField(fields: string[]) { await Promise.all( indexPatternIds.map(async (indexPatternId: string) => { const indexPattern = await indexPatternsService.get(indexPatternId); - const fieldsForIndexPattern = await indexPatternsService.getFieldsForIndexPattern( - indexPattern - ); const containsField = fields.some((field: string) => - fieldsForIndexPattern.some( - (fieldDescriptor: IFieldType) => - fieldDescriptor.esTypes && fieldDescriptor.esTypes.includes(field) + indexPattern.fields.some( + (fieldDescriptor) => fieldDescriptor.esTypes && fieldDescriptor.esTypes.includes(field) ) ); if (containsField) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index b9af6750d6ee9d..f32e60dcf3cc1d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -8,10 +8,8 @@ import React, { FC, Fragment, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiPage, @@ -81,18 +79,6 @@ export const Page: FC = () => { id="xpack.ml.dataframe.analyticsList.title" defaultMessage="Data frame analytics" /> -   -
diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index 70b7632775bf5a..d760ff9455a885 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -159,12 +159,13 @@ export class AnomalyExplorerChartsService { const halfPoints = Math.ceil(plotPoints / 2); const bounds = timeFilter.getActiveBounds(); const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined; + const boundsMax = bounds?.max ? bounds.max.valueOf() : undefined; let chartRange: ChartRange = { min: boundsMin ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin) : midpointMs - halfPoints * minBucketSpanMs, - max: bounds?.max - ? Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()) + max: boundsMax + ? Math.min(midpointMs + halfPoints * minBucketSpanMs, boundsMax) : midpointMs + halfPoints * minBucketSpanMs, }; @@ -210,15 +211,21 @@ export class AnomalyExplorerChartsService { } // Elasticsearch aggregation returns points at start of bucket, - // so align the min to the length of the longest bucket. + // so align the min to the length of the longest bucket, + // and use the start of the latest selected bucket in the check + // for too many selected buckets, respecting the max bounds set in the view. chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; if (boundsMin !== undefined && chartRange.min < boundsMin) { chartRange.min = chartRange.min + maxBucketSpanMs; } + const selectedLatestBucketStart = boundsMax + ? Math.floor(Math.min(selectedLatestMs, boundsMax) / maxBucketSpanMs) * maxBucketSpanMs + : Math.floor(selectedLatestMs / maxBucketSpanMs) * maxBucketSpanMs; + if ( - (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && - chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs + (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestBucketStart) && + chartRange.max - chartRange.min < selectedLatestBucketStart - selectedEarliestMs ) { tooManyBuckets = true; } diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts index bf6e32af0dc391..cd3e28debb7d50 100644 --- a/x-pack/plugins/monitoring/common/constants.ts +++ b/x-pack/plugins/monitoring/common/constants.ts @@ -460,7 +460,7 @@ export const ALERT_DETAILS = { paramDetails: { threshold: { label: i18n.translate('xpack.monitoring.alerts.shardSize.paramDetails.threshold.label', { - defaultMessage: `Notify when a shard exceeds this size`, + defaultMessage: `Notify when average shard size exceeds this value`, }), type: AlertParamType.Number, append: 'GB', @@ -477,7 +477,7 @@ export const ALERT_DETAILS = { defaultMessage: 'Shard size', }), description: i18n.translate('xpack.monitoring.alerts.shardSize.description', { - defaultMessage: 'Alert if an index (primary) shard is oversize.', + defaultMessage: 'Alert if the average shard size is larger than the configured threshold.', }), }, }; diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index 9dce32211f4b1a..38a7e7859272ca 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -100,6 +100,9 @@ export interface ElasticsearchNodeStats { export interface ElasticsearchIndexStats { index?: string; + shards: { + primaries: number; + }; primaries?: { docs?: { count?: number; diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index 2c9e5a04e37e42..db318d7962beb9 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -49,7 +49,7 @@ export class LargeShardSizeAlert extends BaseAlert { description: i18n.translate( 'xpack.monitoring.alerts.shardSize.actionVariables.shardIndex', { - defaultMessage: 'List of indices which are experiencing large shard size.', + defaultMessage: 'List of indices which are experiencing large average shard size.', } ), }, @@ -100,7 +100,7 @@ export class LargeShardSizeAlert extends BaseAlert { const { shardIndex, shardSize } = item.meta as IndexShardSizeUIMeta; return { text: i18n.translate('xpack.monitoring.alerts.shardSize.ui.firingMessage', { - defaultMessage: `The following index: #start_link{shardIndex}#end_link has a large shard size of: {shardSize}GB at #absolute`, + defaultMessage: `The following index: #start_link{shardIndex}#end_link has a large average shard size of: {shardSize}GB at #absolute`, values: { shardIndex, shardSize, diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index f51e1cde47f8d0..c3e9f08c3b9490 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -69,13 +69,6 @@ export async function fetchIndexShardSize( }, aggs: { over_threshold: { - filter: { - range: { - 'index_stats.primaries.store.size_in_bytes': { - gt: threshold * gbMultiplier, - }, - }, - }, aggs: { index: { terms: { @@ -96,6 +89,7 @@ export async function fetchIndexShardSize( _source: { includes: [ '_index', + 'index_stats.shards.primaries', 'index_stats.primaries.store.size_in_bytes', 'source_node.name', 'source_node.uuid', @@ -123,7 +117,7 @@ export async function fetchIndexShardSize( if (!clusterBuckets.length) { return stats; } - + const thresholdBytes = threshold * gbMultiplier; for (const clusterBucket of clusterBuckets) { const indexBuckets = clusterBucket.over_threshold.index.buckets; const clusterUuid = clusterBucket.key; @@ -143,9 +137,25 @@ export async function fetchIndexShardSize( _source: { source_node: sourceNode, index_stats: indexStats }, } = topHit; - const { size_in_bytes: shardSizeBytes } = indexStats?.primaries?.store!; + if (!indexStats || !indexStats.primaries) { + continue; + } + + const { primaries: totalPrimaryShards } = indexStats.shards; + const { size_in_bytes: primaryShardSizeBytes = 0 } = indexStats.primaries.store || {}; + if (!primaryShardSizeBytes || !totalPrimaryShards) { + continue; + } + /** + * We can only calculate the average primary shard size at this point, since we don't have + * data (in .monitoring-es* indices) to give us individual shards. This might change in the future + */ const { name: nodeName, uuid: nodeId } = sourceNode; - const shardSize = +(shardSizeBytes! / gbMultiplier).toFixed(2); + const avgShardSize = primaryShardSizeBytes / totalPrimaryShards; + if (avgShardSize < thresholdBytes) { + continue; + } + const shardSize = +(avgShardSize / gbMultiplier).toFixed(2); stats.push({ shardIndex, shardSize, diff --git a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts index f90a81f73823ed..7e22b5c4ead10a 100644 --- a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts +++ b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts @@ -37,7 +37,7 @@ export const registerDeleteRoute = ({ // Until then we'll modify the response here. if ( err?.meta && - err.body?.task_failures[0]?.reason?.reason?.includes( + err.body?.task_failures?.[0]?.reason?.reason?.includes( 'Job must be [STOPPED] before deletion' ) ) { diff --git a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx index bec1b296d48541..1baa57166de3fb 100644 --- a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx @@ -7,22 +7,12 @@ import { EuiToolTip } from '@elastic/eui'; import React from 'react'; -import styled from 'styled-components'; import { TooltipWithKeyboardShortcut } from '../../components/accessibility/tooltip_with_keyboard_shortcut'; import * as i18n from '../../components/drag_and_drop/translations'; import { Clipboard } from './clipboard'; -const WithCopyToClipboardContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; - user-select: text; -`; - -WithCopyToClipboardContainer.displayName = 'WithCopyToClipboardContainer'; - /** * Renders `children` with an adjacent icon that when clicked, copies `text` to * the clipboard and displays a confirmation toast @@ -31,7 +21,7 @@ export const WithCopyToClipboard = React.memo<{ keyboardShortcut?: string; text: string; titleSummary?: string; -}>(({ keyboardShortcut = '', text, titleSummary, children }) => ( +}>(({ keyboardShortcut = '', text, titleSummary }) => ( } > - - <>{children} - - + )); diff --git a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index b0c02bdbfefc66..a5da747787ba6e 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -718,12 +718,6 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1061', tactics: ['execution'], }, - { - name: 'Group Policy Modification', - id: 'T1484', - reference: 'https://attack.mitre.org/techniques/T1484', - tactics: ['defense-evasion', 'privilege-escalation'], - }, { name: 'Hardware Additions', id: 'T1200', @@ -1354,6 +1348,18 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1220', tactics: ['defense-evasion'], }, + { + name: 'Domain Policy Modification', + id: 'T1484', + reference: 'https://attack.mitre.org/techniques/T1484', + tactics: ['defense-evasion', 'privilege-escalation'], + }, + { + name: 'Forge Web Credentials', + id: 'T1606', + reference: 'https://attack.mitre.org/techniques/T1606', + tactics: ['credential-access'], + }, ]; export const techniquesOptions: MitreTechniquesOptions[] = [ @@ -2259,17 +2265,6 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'execution', value: 'graphicalUserInterface', }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.groupPolicyModificationDescription', - { defaultMessage: 'Group Policy Modification (T1484)' } - ), - id: 'T1484', - name: 'Group Policy Modification', - reference: 'https://attack.mitre.org/techniques/T1484', - tactics: 'defense-evasion,privilege-escalation', - value: 'groupPolicyModification', - }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.hardwareAdditionsDescription', @@ -3425,6 +3420,28 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'defense-evasion', value: 'xslScriptProcessing', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.domainPolicyModificationDescription', + { defaultMessage: 'Domain Policy Modification (T1484)' } + ), + id: 'T1484', + name: 'Domain Policy Modification', + reference: 'https://attack.mitre.org/techniques/T1484', + tactics: 'defense-evasion,privilege-escalation', + value: 'domainPolicyModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.forgeWebCredentialsDescription', + { defaultMessage: 'Forge Web Credentials (T1606)' } + ), + id: 'T1606', + name: 'Forge Web Credentials', + reference: 'https://attack.mitre.org/techniques/T1606', + tactics: 'credential-access', + value: 'forgeWebCredentials', + }, ]; export const subtechniques = [ @@ -3477,13 +3494,6 @@ export const subtechniques = [ tactics: ['persistence'], techniqueId: 'T1137', }, - { - name: 'Additional Cloud Credentials', - id: 'T1098.001', - reference: 'https://attack.mitre.org/techniques/T1098/001', - tactics: ['persistence'], - techniqueId: 'T1098', - }, { name: 'AppCert DLLs', id: 'T1546.009', @@ -5864,6 +5874,41 @@ export const subtechniques = [ tactics: ['persistence', 'privilege-escalation'], techniqueId: 'T1547', }, + { + name: 'Additional Cloud Credentials', + id: 'T1098.001', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: ['persistence'], + techniqueId: 'T1098', + }, + { + name: 'Group Policy Modification', + id: 'T1484.001', + reference: 'https://attack.mitre.org/techniques/T1484/001', + tactics: ['defense-evasion', 'privilege-escalation'], + techniqueId: 'T1484', + }, + { + name: 'Domain Trust Modification', + id: 'T1484.002', + reference: 'https://attack.mitre.org/techniques/T1484/002', + tactics: ['defense-evasion', 'privilege-escalation'], + techniqueId: 'T1484', + }, + { + name: 'Web Cookies', + id: 'T1606.001', + reference: 'https://attack.mitre.org/techniques/T1606/001', + tactics: ['credential-access'], + techniqueId: 'T1606', + }, + { + name: 'SAML Tokens', + id: 'T1606.002', + reference: 'https://attack.mitre.org/techniques/T1606/002', + tactics: ['credential-access'], + techniqueId: 'T1606', + }, ]; export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ @@ -5951,18 +5996,6 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1137', value: 'addIns', }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.additionalCloudCredentialsT1098Description', - { defaultMessage: 'Additional Cloud Credentials (T1098.001)' } - ), - id: 'T1098.001', - name: 'Additional Cloud Credentials', - reference: 'https://attack.mitre.org/techniques/T1098/001', - tactics: 'persistence', - techniqueId: 'T1098', - value: 'additionalCloudCredentials', - }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.appCertDlLsT1546Description', @@ -10043,6 +10076,66 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1547', value: 'winlogonHelperDll', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.additionalCloudCredentialsT1098Description', + { defaultMessage: 'Additional Cloud Credentials (T1098.001)' } + ), + id: 'T1098.001', + name: 'Additional Cloud Credentials', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: 'persistence', + techniqueId: 'T1098', + value: 'additionalCloudCredentials', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.groupPolicyModificationT1484Description', + { defaultMessage: 'Group Policy Modification (T1484.001)' } + ), + id: 'T1484.001', + name: 'Group Policy Modification', + reference: 'https://attack.mitre.org/techniques/T1484/001', + tactics: 'defense-evasion,privilege-escalation', + techniqueId: 'T1484', + value: 'groupPolicyModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.domainTrustModificationT1484Description', + { defaultMessage: 'Domain Trust Modification (T1484.002)' } + ), + id: 'T1484.002', + name: 'Domain Trust Modification', + reference: 'https://attack.mitre.org/techniques/T1484/002', + tactics: 'defense-evasion,privilege-escalation', + techniqueId: 'T1484', + value: 'domainTrustModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.webCookiesT1606Description', + { defaultMessage: 'Web Cookies (T1606.001)' } + ), + id: 'T1606.001', + name: 'Web Cookies', + reference: 'https://attack.mitre.org/techniques/T1606/001', + tactics: 'credential-access', + techniqueId: 'T1606', + value: 'webCookies', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.samlTokensT1606Description', + { defaultMessage: 'SAML Tokens (T1606.002)' } + ), + id: 'T1606.002', + name: 'SAML Tokens', + reference: 'https://attack.mitre.org/techniques/T1606/002', + tactics: 'credential-access', + techniqueId: 'T1606', + value: 'samlTokens', + }, ]; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index dfc14747dacf35..a3fd991da57823 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -62,11 +62,7 @@ const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` } `; -const ExitFullScreenFlexItem = styled(EuiFlexItem)` - &.euiFlexItem { - ${({ theme }) => `margin: ${theme.eui.euiSizeS} 0 0 ${theme.eui.euiSizeS};`} - } - +const ExitFullScreenContainer = styled.div` width: 180px; `; @@ -205,13 +201,15 @@ export const PinnedTabContentComponent: React.FC = ({ return ( <> - {timelineFullScreen && setTimelineFullScreen != null && ( - - - - )} - + {timelineFullScreen && setTimelineFullScreen != null && ( + + + + )} new FixturePlugin(); +export const plugin = (initContext: PluginInitializerContext) => new FixturePlugin(initContext); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts new file mode 100644 index 00000000000000..776686bcd1c0a3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// This module provides a helper to perform retries on a function if the +// function ends up throwing a SavedObject 409 conflict. This can happen +// when alert SO's are updated in the background, and will avoid having to +// have the caller make explicit conflict checks, where the conflict was +// caused by a background update. + +import { Logger } from 'kibana/server'; + +type RetryableForConflicts = () => Promise; + +// number of times to retry when conflicts occur +export const RetryForConflictsAttempts = 2; + +// milliseconds to wait before retrying when conflicts occur +// note: we considered making this random, to help avoid a stampede, but +// with 1 retry it probably doesn't matter, and adding randomness could +// make it harder to diagnose issues +const RetryForConflictsDelay = 250; + +// retry an operation if it runs into 409 Conflict's, up to a limit +export async function retryIfConflicts( + logger: Logger, + name: string, + operation: RetryableForConflicts, + retries: number = RetryForConflictsAttempts +): Promise { + // run the operation, return if no errors or throw if not a conflict error + try { + return await operation(); + } catch (err) { + if (!isConflictError(err)) { + throw err; + } + + // must be a conflict; if no retries left, throw it + if (retries <= 0) { + logger.warn(`${name} conflict, exceeded retries`); + throw err; + } + + // delay a bit before retrying + logger.debug(`${name} conflict, retrying ...`); + await waitBeforeNextRetry(); + return await retryIfConflicts(logger, name, operation, retries - 1); + } +} + +async function waitBeforeNextRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, RetryForConflictsDelay)); +} + +// This is a workaround to avoid having to add more code to compile for tests via +// packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js +// to use SavedObjectsErrorHelpers.isConflictError. +function isConflictError(error: any): boolean { + return error.isBoom === true && error.output.statusCode === 409; +} diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts index 972cb05c997663..bf5d05ee4624a8 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Plugin, CoreSetup } from 'kibana/server'; +import { Plugin, CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../../../../../../plugins/actions/server/plugin'; import { PluginSetupContract as AlertingPluginSetup } from '../../../../../../../plugins/alerting/server/plugin'; import { EncryptedSavedObjectsPluginStart } from '../../../../../../../plugins/encrypted_saved_objects/server'; @@ -29,6 +29,12 @@ export interface FixtureStartDeps { } export class FixturePlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('fixtures', 'plugins', 'alerts'); + } + public setup( core: CoreSetup, { features, actions, alerting }: FixtureSetupDeps @@ -109,7 +115,7 @@ export class FixturePlugin implements Plugin) { +export function defineRoutes(core: CoreSetup, { logger }: { logger: Logger }) { const router = core.http.createRouter(); router.put( { @@ -84,28 +86,35 @@ export function defineRoutes(core: CoreSetup) { throw new Error('Failed to grant an API Key'); } - const result = await savedObjectsWithAlerts.update( - 'alert', - id, - { - ...( - await encryptedSavedObjectsWithAlerts.getDecryptedAsInternalUser( - 'alert', - id, - { - namespace, - } - ) - ).attributes, - apiKey: Buffer.from(`${createAPIKeyResult.id}:${createAPIKeyResult.api_key}`).toString( - 'base64' - ), - apiKeyOwner: user.username, - }, - { - namespace, + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/${id}/replace_api_key`, + async () => { + return await savedObjectsWithAlerts.update( + 'alert', + id, + { + ...( + await encryptedSavedObjectsWithAlerts.getDecryptedAsInternalUser( + 'alert', + id, + { + namespace, + } + ) + ).attributes, + apiKey: Buffer.from( + `${createAPIKeyResult.id}:${createAPIKeyResult.api_key}` + ).toString('base64'), + apiKeyOwner: user.username, + }, + { + namespace, + } + ); } ); + return res.ok({ body: result }); } ); @@ -147,11 +156,17 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['alert'], }); const savedAlert = await savedObjectsWithAlerts.get(type, id); - const result = await savedObjectsWithAlerts.update( - type, - id, - { ...savedAlert.attributes, ...attributes }, - options + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/saved_object/${type}/${id}`, + async () => { + return await savedObjectsWithAlerts.update( + type, + id, + { ...savedAlert.attributes, ...attributes }, + options + ); + } ); return res.ok({ body: result }); } @@ -182,10 +197,16 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['task', 'alert'], }); const alert = await savedObjectsWithTasksAndAlerts.get('alert', id); - const result = await savedObjectsWithTasksAndAlerts.update( - 'task', - alert.attributes.scheduledTaskId!, - { runAt } + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/${id}/reschedule_task`, + async () => { + return await savedObjectsWithTasksAndAlerts.update( + 'task', + alert.attributes.scheduledTaskId!, + { runAt } + ); + } ); return res.ok({ body: result }); } @@ -216,10 +237,16 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['task', 'alert'], }); const alert = await savedObjectsWithTasksAndAlerts.get('alert', id); - const result = await savedObjectsWithTasksAndAlerts.update( - 'task', - alert.attributes.scheduledTaskId!, - { status } + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/{id}/reset_task_status`, + async () => { + return await savedObjectsWithTasksAndAlerts.update( + 'task', + alert.attributes.scheduledTaskId!, + { status } + ); + } ); return res.ok({ body: result }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index 627cb299f0909d..5737794eefeab0 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -19,11 +19,13 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.load('fleet/empty_fleet_server'); }); beforeEach(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); await esArchiver.load('fleet/agents'); }); setupFleetAndAgents(providerContext); afterEach(async () => { await esArchiver.unload('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); }); after(async () => { await esArchiver.unload('fleet/empty_fleet_server'); diff --git a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts index ab765eae18ca5e..d7e16b7e7224bf 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts @@ -23,10 +23,12 @@ export default function (providerContext: FtrProviderContext) { let accessAPIKeyId: string; let outputAPIKeyId: string; before(async () => { - await esArchiver.load('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); }); setupFleetAndAgents(providerContext); beforeEach(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + await esArchiver.load('fleet/agents'); const { body: accessAPIKeyBody } = await esClient.security.createApiKey({ body: { name: `test access api key: ${uuid.v4()}`, @@ -63,8 +65,12 @@ export default function (providerContext: FtrProviderContext) { }, }); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); + }); + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); }); it('/agents/{agent_id}/unenroll should fail for managed policy', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index 41232f73efa5c9..008614f075514a 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -26,12 +26,15 @@ export default function (providerContext: FtrProviderContext) { describe('fleet upgrade', () => { skipIfNoDockerRegistry(providerContext); before(async () => { - await esArchiver.loadIfNeeded('fleet/agents'); + await esArchiver.load('fleet/agents'); }); setupFleetAndAgents(providerContext); beforeEach(async () => { await esArchiver.load('fleet/agents'); }); + afterEach(async () => { + await esArchiver.unload('fleet/agents'); + }); after(async () => { await esArchiver.unload('fleet/agents'); }); diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index 700a06750d2f47..25b4e16535fdae 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -24,7 +24,7 @@ export default function (providerContext: FtrProviderContext) { after(async () => { await esArchiver.unload('empty_kibana'); - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); beforeEach(async () => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/list.ts b/x-pack/test/fleet_api_integration/apis/epm/list.ts index 5a991e52bdba4e..c482f4012d2e5a 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/list.ts @@ -26,7 +26,7 @@ export default function (providerContext: FtrProviderContext) { }); setupFleetAndAgents(providerContext); after(async () => { - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); describe('list api tests', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index c9709475d182d9..4c16a4fbd1cfa6 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -24,7 +24,7 @@ export default function (providerContext: FtrProviderContext) { after(async () => { await esArchiver.unload('empty_kibana'); - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); beforeEach(async () => { try { diff --git a/x-pack/test/functional/es_archives/fleet/agents/mappings.json b/x-pack/test/functional/es_archives/fleet/agents/mappings.json index 5b35fa05a51bfd..72a7368e4d0a82 100644 --- a/x-pack/test/functional/es_archives/fleet/agents/mappings.json +++ b/x-pack/test/functional/es_archives/fleet/agents/mappings.json @@ -3089,7 +3089,7 @@ ".fleet-actions": { } }, - "index": ".fleet-actions_1", + "index": ".fleet-actions-7", "mappings": { "_meta": { "migrationHash": "6527beea5a4a2f33acb599585ed4710442ece7f2" @@ -3136,7 +3136,7 @@ ".fleet-agents": { } }, - "index": ".fleet-agents_1", + "index": ".fleet-agent-7", "mappings": { "_meta": { "migrationHash": "87cab95ac988d78a78d0d66bbf05361b65dcbacf" @@ -3373,7 +3373,7 @@ ".fleet-enrollment-api-keys": { } }, - "index": ".fleet-enrollment-api-keys_1", + "index": ".fleet-enrollment-api-keys-7", "mappings": { "_meta": { "migrationHash": "06bef724726f3bea9f474a09be0a7f7881c28d4a" @@ -3422,7 +3422,7 @@ ".fleet-policies": { } }, - "index": ".fleet-policies_1", + "index": ".fleet-policies-7", "mappings": { "_meta": { "migrationHash": "c2c2a49b19562942fa7c1ff1537e66e751cdb4fa" @@ -3466,7 +3466,7 @@ ".fleet-servers": { } }, - "index": ".fleet-servers_1", + "index": ".fleet-servers-7", "mappings": { "_meta": { "migrationHash": "e2782448c7235ec9af66ca7997e867d715ac379c" diff --git a/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json b/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json index 73f090b6103dc4..a04b7a7dc21c7e 100644 --- a/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json +++ b/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json @@ -5,7 +5,7 @@ ".fleet-actions": { } }, - "index": ".fleet-actions_1", + "index": ".fleet-actions-7", "mappings": { "_meta": { "migrationHash": "6527beea5a4a2f33acb599585ed4710442ece7f2" @@ -52,7 +52,7 @@ ".fleet-agents": { } }, - "index": ".fleet-agents_1", + "index": ".fleet-agents-7", "mappings": { "_meta": { "migrationHash": "87cab95ac988d78a78d0d66bbf05361b65dcbacf" @@ -289,7 +289,7 @@ ".fleet-enrollment-api-keys": { } }, - "index": ".fleet-enrollment-api-keys_1", + "index": ".fleet-enrollment-api-keys-7", "mappings": { "_meta": { "migrationHash": "06bef724726f3bea9f474a09be0a7f7881c28d4a" @@ -338,7 +338,7 @@ ".fleet-policies": { } }, - "index": ".fleet-policies_1", + "index": ".fleet-policies-7", "mappings": { "_meta": { "migrationHash": "c2c2a49b19562942fa7c1ff1537e66e751cdb4fa" @@ -382,7 +382,7 @@ ".fleet-servers": { } }, - "index": ".fleet-servers_1", + "index": ".fleet-servers-7", "mappings": { "_meta": { "migrationHash": "e2782448c7235ec9af66ca7997e867d715ac379c" diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index f47e79260e61c8..525e0d91e2f4d8 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -22,18 +22,25 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider policyName: string, warmEnabled: boolean = false, coldEnabled: boolean = false, - deletePhaseEnabled: boolean = false + deletePhaseEnabled: boolean = false, + minAges: { [key: string]: { value: string; unit: string } } = { + warm: { value: '10', unit: 'd' }, + cold: { value: '15', unit: 'd' }, + frozen: { value: '20', unit: 'd' }, + } ) { await testSubjects.setValue('policyNameField', policyName); if (warmEnabled) { await retry.try(async () => { await testSubjects.click('enablePhaseSwitch-warm'); }); + await testSubjects.setValue('warm-selectedMinimumAge', minAges.warm.value); } if (coldEnabled) { await retry.try(async () => { await testSubjects.click('enablePhaseSwitch-cold'); }); + await testSubjects.setValue('cold-selectedMinimumAge', minAges.cold.value); } if (deletePhaseEnabled) { await retry.try(async () => { @@ -48,10 +55,17 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider policyName: string, warmEnabled: boolean = false, coldEnabled: boolean = false, - deletePhaseEnabled: boolean = false + deletePhaseEnabled: boolean = false, + minAges?: { [key: string]: { value: string; unit: string } } ) { await testSubjects.click('createPolicyButton'); - await this.fillNewPolicyForm(policyName, warmEnabled, coldEnabled, deletePhaseEnabled); + await this.fillNewPolicyForm( + policyName, + warmEnabled, + coldEnabled, + deletePhaseEnabled, + minAges + ); await this.saveNewPolicy(); }, diff --git a/yarn.lock b/yarn.lock index 0e6427d2e265ed..693da02fddfdf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2616,7 +2616,7 @@ version "0.0.0" uid "" -"@kbn/apm-utils@link:packages/kbn-apm-utils": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": version "0.0.0" uid "" @@ -9186,20 +9186,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001181: - version "1.0.30001202" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001202.tgz#4cb3bd5e8a808e8cd89e4e66c549989bc8137201" - integrity sha512-ZcijQNqrcF8JNLjzvEiXqX4JUYxoZa7Pvcsd9UD8Kz4TvhTonOSNRsK+qtvpVL4l6+T1Rh4LFtLfnNWg6BGWCQ== - -caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001173: - version "1.0.30001179" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001179.tgz" - integrity sha512-blMmO0QQujuUWZKyVrD1msR4WNDAqb/UPO1Sw2WWsQ7deoM5bJiicKnWJ1Y0NS/aGINSnKPIWBMw5luX+NDUCA== - -caniuse-lite@^1.0.30001157: - version "1.0.30001164" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001164.tgz#5bbfd64ca605d43132f13cc7fdabb17c3036bfdc" - integrity sha512-G+A/tkf4bu0dSp9+duNiXc7bGds35DioCyC6vgK2m/rjA4Krpy5WeZgZyfH2f0wj2kI6yAWWucyap6oOwmY1mg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001157, caniuse-lite@^1.0.30001173, caniuse-lite@^1.0.30001181: + version "1.0.30001208" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz" + integrity sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA== capture-exit@^2.0.0: version "2.0.0"