{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && (
diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx
new file mode 100644
index 00000000000000..d4703d14627a46
--- /dev/null
+++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { Capabilities } from 'src/core/public';
+import { showPublicUrlSwitch } from './show_share_modal';
+
+describe('showPublicUrlSwitch', () => {
+ test('returns false if "dashboard" app is not available', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(false);
+ });
+
+ test('returns false if "dashboard" app is not accessible', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ dashboard: {
+ show: false,
+ },
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(false);
+ });
+
+ test('returns true if "dashboard" app is not available an accessible', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ dashboard: {
+ show: true,
+ },
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx
index ecebef2ec3c9c2..fe4f8ea4112896 100644
--- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx
+++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx
@@ -6,6 +6,7 @@
* Public License, v 1.
*/
+import { Capabilities } from 'src/core/public';
import { EuiCheckboxGroup } from '@elastic/eui';
import React from 'react';
import { ReactElement, useState } from 'react';
@@ -27,6 +28,14 @@ interface ShowShareModalProps {
dashboardStateManager: DashboardStateManager;
}
+export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => {
+ if (!anonymousUserCapabilities.dashboard) return false;
+
+ const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities;
+
+ return !!dashboard.show;
+};
+
export function ShowShareModal({
share,
anchorElement,
@@ -113,5 +122,6 @@ export function ShowShareModal({
component: EmbedUrlParamExtension,
},
],
+ showPublicUrlSwitch,
});
}
diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts
index cc784f5f81c9e8..4bd43d1cd64a90 100644
--- a/src/plugins/dashboard/server/index.ts
+++ b/src/plugins/dashboard/server/index.ts
@@ -25,3 +25,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export { DashboardPluginSetup, DashboardPluginStart } from './types';
+export { findByValueEmbeddables } from './usage/find_by_value_embeddables';
diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts
new file mode 100644
index 00000000000000..3da6a8050f14c4
--- /dev/null
+++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { SavedDashboardPanel730ToLatest } from '../../common';
+import { findByValueEmbeddables } from './find_by_value_embeddables';
+
+const visualizationByValue = ({
+ embeddableConfig: {
+ value: 'visualization-by-value',
+ },
+ type: 'visualization',
+} as unknown) as SavedDashboardPanel730ToLatest;
+
+const mapByValue = ({
+ embeddableConfig: {
+ value: 'map-by-value',
+ },
+ type: 'map',
+} as unknown) as SavedDashboardPanel730ToLatest;
+
+const embeddableByRef = ({
+ panelRefName: 'panel_ref_1',
+} as unknown) as SavedDashboardPanel730ToLatest;
+
+describe('findByValueEmbeddables', () => {
+ it('finds the by value embeddables for the given type', async () => {
+ const savedObjectsResult = {
+ saved_objects: [
+ {
+ attributes: {
+ panelsJSON: JSON.stringify([visualizationByValue, mapByValue, embeddableByRef]),
+ },
+ },
+ {
+ attributes: {
+ panelsJSON: JSON.stringify([embeddableByRef, mapByValue, visualizationByValue]),
+ },
+ },
+ ],
+ };
+ const savedObjectClient = { find: jest.fn().mockResolvedValue(savedObjectsResult) };
+
+ const maps = await findByValueEmbeddables(savedObjectClient, 'map');
+
+ expect(maps.length).toBe(2);
+ expect(maps[0]).toEqual(mapByValue.embeddableConfig);
+ expect(maps[1]).toEqual(mapByValue.embeddableConfig);
+
+ const visualizations = await findByValueEmbeddables(savedObjectClient, 'visualization');
+
+ expect(visualizations.length).toBe(2);
+ expect(visualizations[0]).toEqual(visualizationByValue.embeddableConfig);
+ expect(visualizations[1]).toEqual(visualizationByValue.embeddableConfig);
+ });
+});
diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts
new file mode 100644
index 00000000000000..0ae14cdcf71975
--- /dev/null
+++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server';
+import { SavedDashboardPanel730ToLatest } from '../../common';
+
+export const findByValueEmbeddables = async (
+ savedObjectClient: Pick
,
+ embeddableType: string
+) => {
+ const dashboards = await savedObjectClient.find({
+ type: 'dashboard',
+ });
+
+ return dashboards.saved_objects
+ .map((dashboard) => {
+ try {
+ return (JSON.parse(
+ dashboard.attributes.panelsJSON as string
+ ) as unknown) as SavedDashboardPanel730ToLatest[];
+ } catch (exception) {
+ return [];
+ }
+ })
+ .flat()
+ .filter((panel) => (panel as Record).panelRefName === undefined)
+ .filter((panel) => panel.type === embeddableType)
+ .map((panel) => panel.embeddableConfig);
+};
diff --git a/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts b/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts
new file mode 100644
index 00000000000000..df78d68aaef487
--- /dev/null
+++ b/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts
@@ -0,0 +1,280 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { nodeBuilder } from './node_builder';
+import { toElasticsearchQuery } from '../index';
+
+describe('nodeBuilder', () => {
+ describe('is method', () => {
+ test('string value', () => {
+ const nodes = nodeBuilder.is('foo', 'bar');
+ const query = toElasticsearchQuery(nodes);
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo": "bar",
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+
+ test('KueryNode value', () => {
+ const literalValue = {
+ type: 'literal' as 'literal',
+ value: 'bar',
+ };
+ const nodes = nodeBuilder.is('foo', literalValue);
+ const query = toElasticsearchQuery(nodes);
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo": "bar",
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+ });
+
+ describe('and method', () => {
+ test('single clause', () => {
+ const nodes = [nodeBuilder.is('foo', 'bar')];
+ const query = toElasticsearchQuery(nodeBuilder.and(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo": "bar",
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+
+ test('two clauses', () => {
+ const nodes = [nodeBuilder.is('foo1', 'bar1'), nodeBuilder.is('foo2', 'bar2')];
+ const query = toElasticsearchQuery(nodeBuilder.and(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo1": "bar1",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo2": "bar2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+
+ test('three clauses', () => {
+ const nodes = [
+ nodeBuilder.is('foo1', 'bar1'),
+ nodeBuilder.is('foo2', 'bar2'),
+ nodeBuilder.is('foo3', 'bar3'),
+ ];
+ const query = toElasticsearchQuery(nodeBuilder.and(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo1": "bar1",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo2": "bar2",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo3": "bar3",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+ });
+
+ describe('or method', () => {
+ test('single clause', () => {
+ const nodes = [nodeBuilder.is('foo', 'bar')];
+ const query = toElasticsearchQuery(nodeBuilder.or(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo": "bar",
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+
+ test('two clauses', () => {
+ const nodes = [nodeBuilder.is('foo1', 'bar1'), nodeBuilder.is('foo2', 'bar2')];
+ const query = toElasticsearchQuery(nodeBuilder.or(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo1": "bar1",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo2": "bar2",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+
+ test('three clauses', () => {
+ const nodes = [
+ nodeBuilder.is('foo1', 'bar1'),
+ nodeBuilder.is('foo2', 'bar2'),
+ nodeBuilder.is('foo3', 'bar3'),
+ ];
+ const query = toElasticsearchQuery(nodeBuilder.or(nodes));
+ expect(query).toMatchInlineSnapshot(`
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo1": "bar1",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo2": "bar2",
+ },
+ },
+ ],
+ },
+ },
+ Object {
+ "bool": Object {
+ "minimum_should_match": 1,
+ "should": Array [
+ Object {
+ "match": Object {
+ "foo3": "bar3",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ }
+ `);
+ });
+ });
+});
diff --git a/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts b/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts
index a72c7f2db41a8a..6da9c3aa293ef5 100644
--- a/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts
+++ b/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts
@@ -16,12 +16,10 @@ export const nodeBuilder = {
nodeTypes.literal.buildNode(false),
]);
},
- or: ([first, ...args]: KueryNode[]): KueryNode => {
- return args.length ? nodeTypes.function.buildNode('or', [first, nodeBuilder.or(args)]) : first;
+ or: (nodes: KueryNode[]): KueryNode => {
+ return nodes.length > 1 ? nodeTypes.function.buildNode('or', nodes) : nodes[0];
},
- and: ([first, ...args]: KueryNode[]): KueryNode => {
- return args.length
- ? nodeTypes.function.buildNode('and', [first, nodeBuilder.and(args)])
- : first;
+ and: (nodes: KueryNode[]): KueryNode => {
+ return nodes.length > 1 ? nodeTypes.function.buildNode('and', nodes) : nodes[0];
},
};
diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts
index 328f05fac8594a..08fe2b07096bb7 100644
--- a/src/plugins/data/common/search/search_source/mocks.ts
+++ b/src/plugins/data/common/search/search_source/mocks.ts
@@ -6,7 +6,7 @@
* Public License, v 1.
*/
-import { BehaviorSubject } from 'rxjs';
+import { BehaviorSubject, of } from 'rxjs';
import type { MockedKeys } from '@kbn/utility-types/jest';
import { uiSettingsServiceMock } from '../../../../../core/public/mocks';
@@ -27,6 +27,7 @@ export const searchSourceInstanceMock: MockedKeys = {
createChild: jest.fn().mockReturnThis(),
setParent: jest.fn(),
getParent: jest.fn().mockReturnThis(),
+ fetch$: jest.fn().mockReturnValue(of({})),
fetch: jest.fn().mockResolvedValue({}),
onRequestStart: jest.fn(),
getSearchRequestBody: jest.fn(),
diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts
index 6d7654c6659f23..c2a4beb9b61a52 100644
--- a/src/plugins/data/common/search/search_source/search_source.test.ts
+++ b/src/plugins/data/common/search/search_source/search_source.test.ts
@@ -51,7 +51,14 @@ describe('SearchSource', () => {
let searchSource: SearchSource;
beforeEach(() => {
- mockSearchMethod = jest.fn().mockReturnValue(of({ rawResponse: '' }));
+ mockSearchMethod = jest
+ .fn()
+ .mockReturnValue(
+ of(
+ { rawResponse: { isPartial: true, isRunning: true } },
+ { rawResponse: { isPartial: false, isRunning: false } }
+ )
+ );
searchSourceDependencies = {
getConfig: jest.fn(),
@@ -564,6 +571,34 @@ describe('SearchSource', () => {
await searchSource.fetch(options);
expect(mockSearchMethod).toBeCalledTimes(1);
});
+
+ test('should return partial results', (done) => {
+ searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
+ const options = {};
+
+ const next = jest.fn();
+ const complete = () => {
+ expect(next).toBeCalledTimes(2);
+ expect(next.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "isPartial": true,
+ "isRunning": true,
+ },
+ ]
+ `);
+ expect(next.mock.calls[1]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "isPartial": false,
+ "isRunning": false,
+ },
+ ]
+ `);
+ done();
+ };
+ searchSource.fetch$(options).subscribe({ next, complete });
+ });
});
describe('#serialize', () => {
diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts
index 554e8385881f23..bb60f0d7b4ad48 100644
--- a/src/plugins/data/common/search/search_source/search_source.ts
+++ b/src/plugins/data/common/search/search_source/search_source.ts
@@ -60,7 +60,8 @@
import { setWith } from '@elastic/safer-lodash-set';
import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash';
-import { map } from 'rxjs/operators';
+import { map, switchMap, tap } from 'rxjs/operators';
+import { defer, from } from 'rxjs';
import { normalizeSortRequest } from './normalize_sort_request';
import { fieldWildcardFilter } from '../../../../kibana_utils/common';
import { IIndexPattern } from '../../index_patterns';
@@ -244,30 +245,35 @@ export class SearchSource {
}
/**
- * Fetch this source and reject the returned Promise on error
- *
- * @async
+ * Fetch this source from Elasticsearch, returning an observable over the response(s)
+ * @param options
*/
- async fetch(options: ISearchOptions = {}) {
+ fetch$(options: ISearchOptions = {}) {
const { getConfig } = this.dependencies;
- await this.requestIsStarting(options);
-
- const searchRequest = await this.flatten();
- this.history = [searchRequest];
-
- let response;
- if (getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)) {
- response = await this.legacyFetch(searchRequest, options);
- } else {
- response = await this.fetchSearch(searchRequest, options);
- }
-
- // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved
- if ((response as any).error) {
- throw new RequestFailure(null, response);
- }
+ return defer(() => this.requestIsStarting(options)).pipe(
+ switchMap(() => {
+ const searchRequest = this.flatten();
+ this.history = [searchRequest];
+
+ return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)
+ ? from(this.legacyFetch(searchRequest, options))
+ : this.fetchSearch$(searchRequest, options);
+ }),
+ tap((response) => {
+ // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved
+ if ((response as any).error) {
+ throw new RequestFailure(null, response);
+ }
+ })
+ );
+ }
- return response;
+ /**
+ * Fetch this source and reject the returned Promise on error
+ * @deprecated Use fetch$ instead
+ */
+ fetch(options: ISearchOptions = {}) {
+ return this.fetch$(options).toPromise();
}
/**
@@ -305,16 +311,16 @@ export class SearchSource {
* Run a search using the search service
* @return {Promise>}
*/
- private fetchSearch(searchRequest: SearchRequest, options: ISearchOptions) {
+ private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) {
const { search, getConfig, onResponse } = this.dependencies;
const params = getSearchParamsFromRequest(searchRequest, {
getConfig,
});
- return search({ params, indexType: searchRequest.indexType }, options)
- .pipe(map(({ rawResponse }) => onResponse(searchRequest, rawResponse)))
- .toPromise();
+ return search({ params, indexType: searchRequest.indexType }, options).pipe(
+ map(({ rawResponse }) => onResponse(searchRequest, rawResponse))
+ );
}
/**
diff --git a/src/plugins/data/common/search/test_data/illegal_argument_exception.json b/src/plugins/data/common/search/test_data/illegal_argument_exception.json
new file mode 100644
index 00000000000000..ae48468abc209d
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/illegal_argument_exception.json
@@ -0,0 +1,14 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "illegal_argument_exception",
+ "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized"
+ }
+ ],
+ "type" : "illegal_argument_exception",
+ "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized"
+ },
+ "status" : 400
+ }
+
\ No newline at end of file
diff --git a/src/plugins/data/common/search/test_data/index_not_found_exception.json b/src/plugins/data/common/search/test_data/index_not_found_exception.json
new file mode 100644
index 00000000000000..dc892d95ae3974
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/index_not_found_exception.json
@@ -0,0 +1,21 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "index_not_found_exception",
+ "reason" : "no such index [poop]",
+ "resource.type" : "index_or_alias",
+ "resource.id" : "poop",
+ "index_uuid" : "_na_",
+ "index" : "poop"
+ }
+ ],
+ "type" : "index_not_found_exception",
+ "reason" : "no such index [poop]",
+ "resource.type" : "index_or_alias",
+ "resource.id" : "poop",
+ "index_uuid" : "_na_",
+ "index" : "poop"
+ },
+ "status" : 404
+}
diff --git a/src/plugins/data/common/search/test_data/json_e_o_f_exception.json b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json
new file mode 100644
index 00000000000000..88134e1c6ea03b
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json
@@ -0,0 +1,14 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "json_e_o_f_exception",
+ "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]"
+ }
+ ],
+ "type" : "json_e_o_f_exception",
+ "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]"
+ },
+ "status" : 400
+ }
+
\ No newline at end of file
diff --git a/src/plugins/data/common/search/test_data/parsing_exception.json b/src/plugins/data/common/search/test_data/parsing_exception.json
new file mode 100644
index 00000000000000..725a847aa0e3f5
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/parsing_exception.json
@@ -0,0 +1,17 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "parsing_exception",
+ "reason" : "[terms] query does not support [ohno]",
+ "line" : 4,
+ "col" : 17
+ }
+ ],
+ "type" : "parsing_exception",
+ "reason" : "[terms] query does not support [ohno]",
+ "line" : 4,
+ "col" : 17
+ },
+ "status" : 400
+}
diff --git a/src/plugins/data/common/search/test_data/resource_not_found_exception.json b/src/plugins/data/common/search/test_data/resource_not_found_exception.json
new file mode 100644
index 00000000000000..7f2a3b2e6e1439
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/resource_not_found_exception.json
@@ -0,0 +1,13 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "resource_not_found_exception",
+ "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk="
+ }
+ ],
+ "type" : "resource_not_found_exception",
+ "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk="
+ },
+ "status" : 404
+}
diff --git a/src/plugins/data/common/search/test_data/search_phase_execution_exception.json b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json
new file mode 100644
index 00000000000000..ff6879f2b89609
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json
@@ -0,0 +1,52 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "script_exception",
+ "reason" : "compile error",
+ "script_stack" : [
+ "invalid",
+ "^---- HERE"
+ ],
+ "script" : "invalid",
+ "lang" : "painless",
+ "position" : {
+ "offset" : 0,
+ "start" : 0,
+ "end" : 7
+ }
+ }
+ ],
+ "type" : "search_phase_execution_exception",
+ "reason" : "all shards failed",
+ "phase" : "query",
+ "grouped" : true,
+ "failed_shards" : [
+ {
+ "shard" : 0,
+ "index" : ".kibana_11",
+ "node" : "b3HX8C96Q7q1zgfVLxEsPA",
+ "reason" : {
+ "type" : "script_exception",
+ "reason" : "compile error",
+ "script_stack" : [
+ "invalid",
+ "^---- HERE"
+ ],
+ "script" : "invalid",
+ "lang" : "painless",
+ "position" : {
+ "offset" : 0,
+ "start" : 0,
+ "end" : 7
+ },
+ "caused_by" : {
+ "type" : "illegal_argument_exception",
+ "reason" : "cannot resolve symbol [invalid]"
+ }
+ }
+ }
+ ]
+ },
+ "status" : 400
+}
diff --git a/src/plugins/data/common/search/test_data/x_content_parse_exception.json b/src/plugins/data/common/search/test_data/x_content_parse_exception.json
new file mode 100644
index 00000000000000..cd6e1cb2c5977d
--- /dev/null
+++ b/src/plugins/data/common/search/test_data/x_content_parse_exception.json
@@ -0,0 +1,17 @@
+{
+ "error" : {
+ "root_cause" : [
+ {
+ "type" : "x_content_parse_exception",
+ "reason" : "[5:13] [script] failed to parse object"
+ }
+ ],
+ "type" : "x_content_parse_exception",
+ "reason" : "[5:13] [script] failed to parse object",
+ "caused_by" : {
+ "type" : "json_parse_exception",
+ "reason" : "Unexpected character (''' (code 39)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (org.elasticsearch.common.bytes.AbstractBytesReference$BytesReferenceStreamInput); line: 5, column: 24]"
+ }
+ },
+ "status" : 400
+}
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index 4f4c8de5bed453..4f197dd43a83ef 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -2283,8 +2283,11 @@ export class SearchInterceptor {
protected readonly deps: SearchInterceptorDeps;
// (undocumented)
protected getTimeoutMode(): TimeoutErrorMode;
+ // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts
+ // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts
+ //
// (undocumented)
- protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error;
+ protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error;
// @internal
protected pendingCount$: BehaviorSubject;
// @internal (undocumented)
@@ -2361,6 +2364,8 @@ export class SearchSource {
createChild(options?: {}): SearchSource;
createCopy(): SearchSource;
destroy(): void;
+ fetch$(options?: ISearchOptions): import("rxjs").Observable>;
+ // @deprecated
fetch(options?: ISearchOptions): Promise>;
getField(field: K, recurse?: boolean): SearchSourceFields[K];
getFields(): {
@@ -2452,7 +2457,7 @@ export interface SearchSourceFields {
//
// @public
export class SearchTimeoutError extends KbnError {
- constructor(err: Error, mode: TimeoutErrorMode);
+ constructor(err: Record, mode: TimeoutErrorMode);
// (undocumented)
getErrorMessage(application: ApplicationStart): JSX.Element;
// (undocumented)
@@ -2602,7 +2607,7 @@ export const UI_SETTINGS: {
// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts
// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts
// src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts
-// src/plugins/data/common/search/search_source/search_source.ts:186:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts
+// src/plugins/data/common/search/search_source/search_source.ts:187:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts
diff --git a/src/plugins/data/public/search/errors/es_error.test.tsx b/src/plugins/data/public/search/errors/es_error.test.tsx
index adb422c1d18e75..6a4cb9c494b4f9 100644
--- a/src/plugins/data/public/search/errors/es_error.test.tsx
+++ b/src/plugins/data/public/search/errors/es_error.test.tsx
@@ -7,23 +7,22 @@
*/
import { EsError } from './es_error';
-import { IEsError } from './types';
describe('EsError', () => {
it('contains the same body as the wrapped error', () => {
const error = {
- body: {
- attributes: {
- error: {
- type: 'top_level_exception_type',
- reason: 'top-level reason',
- },
+ statusCode: 500,
+ message: 'nope',
+ attributes: {
+ error: {
+ type: 'top_level_exception_type',
+ reason: 'top-level reason',
},
},
- } as IEsError;
+ } as any;
const esError = new EsError(error);
- expect(typeof esError.body).toEqual('object');
- expect(esError.body).toEqual(error.body);
+ expect(typeof esError.attributes).toEqual('object');
+ expect(esError.attributes).toEqual(error.attributes);
});
});
diff --git a/src/plugins/data/public/search/errors/es_error.tsx b/src/plugins/data/public/search/errors/es_error.tsx
index fff06d2e1bfb64..d241eecfd8d5dd 100644
--- a/src/plugins/data/public/search/errors/es_error.tsx
+++ b/src/plugins/data/public/search/errors/es_error.tsx
@@ -11,19 +11,19 @@ import { EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { ApplicationStart } from 'kibana/public';
import { KbnError } from '../../../../kibana_utils/common';
import { IEsError } from './types';
-import { getRootCause, getTopLevelCause } from './utils';
+import { getRootCause } from './utils';
export class EsError extends KbnError {
- readonly body: IEsError['body'];
+ readonly attributes: IEsError['attributes'];
constructor(protected readonly err: IEsError) {
super('EsError');
- this.body = err.body;
+ this.attributes = err.attributes;
}
public getErrorMessage(application: ApplicationStart) {
const rootCause = getRootCause(this.err)?.reason;
- const topLevelCause = getTopLevelCause(this.err)?.reason;
+ const topLevelCause = this.attributes?.reason;
const cause = rootCause ?? topLevelCause;
return (
diff --git a/src/plugins/data/public/search/errors/painless_error.test.tsx b/src/plugins/data/public/search/errors/painless_error.test.tsx
new file mode 100644
index 00000000000000..929f25e234a604
--- /dev/null
+++ b/src/plugins/data/public/search/errors/painless_error.test.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
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { coreMock } from '../../../../../core/public/mocks';
+const startMock = coreMock.createStart();
+
+import { mount } from 'enzyme';
+import { PainlessError } from './painless_error';
+import { findTestSubject } from '@elastic/eui/lib/test';
+import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json';
+
+describe('PainlessError', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Should show reason and code', () => {
+ const e = new PainlessError({
+ statusCode: 400,
+ message: 'search_phase_execution_exception',
+ attributes: searchPhaseException.error,
+ });
+ const component = mount(e.getErrorMessage(startMock.application));
+
+ const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode();
+
+ const failedShards = e.attributes?.failed_shards![0];
+ const script = failedShards!.reason.script;
+ expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`);
+
+ const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode();
+ const stackTrace = failedShards!.reason.script_stack!.join('\n');
+ expect(stackTraceElem.textContent).toBe(stackTrace);
+
+ expect(component.find('EuiButton').length).toBe(1);
+ });
+});
diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx
index 8a4248e48185bc..6d11f3a16b09e8 100644
--- a/src/plugins/data/public/search/errors/painless_error.tsx
+++ b/src/plugins/data/public/search/errors/painless_error.tsx
@@ -33,10 +33,12 @@ export class PainlessError extends EsError {
return (
<>
- {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', {
- defaultMessage: "Error executing Painless script: '{script}'.",
- values: { script: rootCause?.script },
- })}
+
+ {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', {
+ defaultMessage: "Error executing Painless script: '{script}'",
+ values: { script: rootCause?.script },
+ })}
+
{painlessStack ? (
diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx
index ee2703b888bf17..6b9ce1b422481f 100644
--- a/src/plugins/data/public/search/errors/timeout_error.tsx
+++ b/src/plugins/data/public/search/errors/timeout_error.tsx
@@ -24,7 +24,7 @@ export enum TimeoutErrorMode {
*/
export class SearchTimeoutError extends KbnError {
public mode: TimeoutErrorMode;
- constructor(err: Error, mode: TimeoutErrorMode) {
+ constructor(err: Record, mode: TimeoutErrorMode) {
super(`Request timeout: ${JSON.stringify(err?.message)}`);
this.mode = mode;
}
diff --git a/src/plugins/data/public/search/errors/types.ts b/src/plugins/data/public/search/errors/types.ts
index d62cb311bf6a43..5806ef8676b9bd 100644
--- a/src/plugins/data/public/search/errors/types.ts
+++ b/src/plugins/data/public/search/errors/types.ts
@@ -6,57 +6,47 @@
* Public License, v 1.
*/
+import { KibanaServerError } from '../../../../kibana_utils/common';
+
export interface FailedShard {
shard: number;
index: string;
node: string;
- reason: {
+ reason: Reason;
+}
+
+export interface Reason {
+ type: string;
+ reason: string;
+ script_stack?: string[];
+ position?: {
+ offset: number;
+ start: number;
+ end: number;
+ };
+ lang?: string;
+ script?: string;
+ caused_by?: {
type: string;
reason: string;
- script_stack: string[];
- script: string;
- lang: string;
- position: {
- offset: number;
- start: number;
- end: number;
- };
- caused_by: {
- type: string;
- reason: string;
- };
};
}
-export interface IEsError {
- body: {
- statusCode: number;
- error: string;
- message: string;
- attributes?: {
- error?: {
- root_cause?: [
- {
- lang: string;
- script: string;
- }
- ];
- type: string;
- reason: string;
- failed_shards: FailedShard[];
- caused_by: {
- type: string;
- reason: string;
- phase: string;
- grouped: boolean;
- failed_shards: FailedShard[];
- script_stack: string[];
- };
- };
- };
- };
+export interface IEsErrorAttributes {
+ type: string;
+ reason: string;
+ root_cause?: Reason[];
+ failed_shards?: FailedShard[];
}
+export type IEsError = KibanaServerError;
+
+/**
+ * Checks if a given errors originated from Elasticsearch.
+ * Those params are assigned to the attributes property of an error.
+ *
+ * @param e
+ */
export function isEsError(e: any): e is IEsError {
- return !!e.body?.attributes;
+ return !!e.attributes;
}
diff --git a/src/plugins/data/public/search/errors/utils.ts b/src/plugins/data/public/search/errors/utils.ts
index d140e713f9440d..7d303543a0c57d 100644
--- a/src/plugins/data/public/search/errors/utils.ts
+++ b/src/plugins/data/public/search/errors/utils.ts
@@ -6,19 +6,15 @@
* Public License, v 1.
*/
-import { IEsError } from './types';
+import { FailedShard } from './types';
+import { KibanaServerError } from '../../../../kibana_utils/common';
-export function getFailedShards(err: IEsError) {
- const failedShards =
- err.body?.attributes?.error?.failed_shards ||
- err.body?.attributes?.error?.caused_by?.failed_shards;
+export function getFailedShards(err: KibanaServerError): FailedShard | undefined {
+ const errorInfo = err.attributes;
+ const failedShards = errorInfo?.failed_shards || errorInfo?.caused_by?.failed_shards;
return failedShards ? failedShards[0] : undefined;
}
-export function getTopLevelCause(err: IEsError) {
- return err.body?.attributes?.error;
-}
-
-export function getRootCause(err: IEsError) {
+export function getRootCause(err: KibanaServerError) {
return getFailedShards(err)?.reason;
}
diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts
index 5ae01eccdd920c..bfd73951c31c48 100644
--- a/src/plugins/data/public/search/search_interceptor.test.ts
+++ b/src/plugins/data/public/search/search_interceptor.test.ts
@@ -12,12 +12,15 @@ import { coreMock } from '../../../../core/public/mocks';
import { IEsSearchRequest } from '../../common/search';
import { SearchInterceptor } from './search_interceptor';
import { AbortError } from '../../../kibana_utils/public';
-import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors';
+import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors';
import { searchServiceMock } from './mocks';
import { ISearchStart, ISessionService } from '.';
import { bfetchPluginMock } from '../../../bfetch/public/mocks';
import { BfetchPublicSetup } from 'src/plugins/bfetch/public';
+import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json';
+import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json';
+
let searchInterceptor: SearchInterceptor;
let mockCoreSetup: MockedKeys;
let bfetchSetup: jest.Mocked;
@@ -64,15 +67,9 @@ describe('SearchInterceptor', () => {
test('Renders a PainlessError', async () => {
searchInterceptor.showError(
new PainlessError({
- body: {
- attributes: {
- error: {
- failed_shards: {
- reason: 'bananas',
- },
- },
- },
- } as any,
+ statusCode: 400,
+ message: 'search_phase_execution_exception',
+ attributes: searchPhaseException.error,
})
);
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
@@ -161,10 +158,8 @@ describe('SearchInterceptor', () => {
describe('Should handle Timeout errors', () => {
test('Should throw SearchTimeoutError on server timeout AND show toast', async () => {
const mockResponse: any = {
- result: 500,
- body: {
- message: 'Request timed out',
- },
+ statusCode: 500,
+ message: 'Request timed out',
};
fetchMock.mockRejectedValueOnce(mockResponse);
const mockRequest: IEsSearchRequest = {
@@ -177,10 +172,8 @@ describe('SearchInterceptor', () => {
test('Timeout error should show multiple times if not in a session', async () => {
const mockResponse: any = {
- result: 500,
- body: {
- message: 'Request timed out',
- },
+ statusCode: 500,
+ message: 'Request timed out',
};
fetchMock.mockRejectedValue(mockResponse);
const mockRequest: IEsSearchRequest = {
@@ -198,10 +191,8 @@ describe('SearchInterceptor', () => {
test('Timeout error should show once per each session', async () => {
const mockResponse: any = {
- result: 500,
- body: {
- message: 'Request timed out',
- },
+ statusCode: 500,
+ message: 'Request timed out',
};
fetchMock.mockRejectedValue(mockResponse);
const mockRequest: IEsSearchRequest = {
@@ -219,10 +210,8 @@ describe('SearchInterceptor', () => {
test('Timeout error should show once in a single session', async () => {
const mockResponse: any = {
- result: 500,
- body: {
- message: 'Request timed out',
- },
+ statusCode: 500,
+ message: 'Request timed out',
};
fetchMock.mockRejectedValue(mockResponse);
const mockRequest: IEsSearchRequest = {
@@ -240,22 +229,9 @@ describe('SearchInterceptor', () => {
test('Should throw Painless error on server error with OSS format', async () => {
const mockResponse: any = {
- result: 500,
- body: {
- attributes: {
- error: {
- failed_shards: [
- {
- reason: {
- lang: 'painless',
- script_stack: ['a', 'b'],
- reason: 'banana',
- },
- },
- ],
- },
- },
- },
+ statusCode: 400,
+ message: 'search_phase_execution_exception',
+ attributes: searchPhaseException.error,
};
fetchMock.mockRejectedValueOnce(mockResponse);
const mockRequest: IEsSearchRequest = {
@@ -265,6 +241,20 @@ describe('SearchInterceptor', () => {
await expect(response.toPromise()).rejects.toThrow(PainlessError);
});
+ test('Should throw ES error on ES server error', async () => {
+ const mockResponse: any = {
+ statusCode: 400,
+ message: 'resource_not_found_exception',
+ attributes: resourceNotFoundException.error,
+ };
+ fetchMock.mockRejectedValueOnce(mockResponse);
+ const mockRequest: IEsSearchRequest = {
+ params: {},
+ };
+ const response = searchInterceptor.search(mockRequest);
+ await expect(response.toPromise()).rejects.toThrow(EsError);
+ });
+
test('Observable should fail if user aborts (test merged signal)', async () => {
const abortController = new AbortController();
fetchMock.mockImplementationOnce((options: any) => {
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
index f6ca9ef1a993de..6dfc8faea769ea 100644
--- a/src/plugins/data/public/search/search_interceptor.ts
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -6,7 +6,7 @@
* Public License, v 1.
*/
-import { get, memoize } from 'lodash';
+import { memoize } from 'lodash';
import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
import { PublicMethodsOf } from '@kbn/utility-types';
@@ -25,7 +25,11 @@ import {
getHttpError,
} from './errors';
import { toMountPoint } from '../../../kibana_react/public';
-import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public';
+import {
+ AbortError,
+ getCombinedAbortSignal,
+ KibanaServerError,
+} from '../../../kibana_utils/public';
import { ISessionService } from './session';
export interface SearchInterceptorDeps {
@@ -87,8 +91,12 @@ export class SearchInterceptor {
* @returns `Error` a search service specific error or the original error, if a specific error can't be recognized.
* @internal
*/
- protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error {
- if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') {
+ protected handleSearchError(
+ e: KibanaServerError | AbortError,
+ timeoutSignal: AbortSignal,
+ options?: ISearchOptions
+ ): Error {
+ if (timeoutSignal.aborted || e.message === 'Request timed out') {
// Handle a client or a server side timeout
const err = new SearchTimeoutError(e, this.getTimeoutMode());
@@ -96,7 +104,7 @@ export class SearchInterceptor {
// The timeout error is shown any time a request times out, or once per session, if the request is part of a session.
this.showTimeoutError(err, options?.sessionId);
return err;
- } else if (options?.abortSignal?.aborted) {
+ } else if (e instanceof AbortError) {
// In the case an application initiated abort, throw the existing AbortError.
return e;
} else if (isEsError(e)) {
@@ -106,12 +114,13 @@ export class SearchInterceptor {
return new EsError(e);
}
} else {
- return e;
+ return e instanceof Error ? e : new Error(e.message);
}
}
/**
* @internal
+ * @throws `AbortError` | `ErrorLike`
*/
protected runSearch(
request: IKibanaSearchRequest,
@@ -234,7 +243,7 @@ export class SearchInterceptor {
});
this.pendingCount$.next(this.pendingCount$.getValue() + 1);
return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe(
- catchError((e: Error) => {
+ catchError((e: Error | AbortError) => {
return throwError(this.handleSearchError(e, timeoutSignal, options));
}),
finalize(() => {
diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx
index 9784ab7116cfb4..b7d9be485a3033 100644
--- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx
+++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx
@@ -84,7 +84,10 @@ function wrapQueryStringInputInContext(testProps: any, storage?: any) {
);
}
-describe('QueryStringInput', () => {
+// FAILING: https://github.com/elastic/kibana/issues/85715
+// FAILING: https://github.com/elastic/kibana/issues/89603
+// FAILING: https://github.com/elastic/kibana/issues/89641
+describe.skip('QueryStringInput', () => {
beforeEach(() => {
jest.clearAllMocks();
});
diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
index 8e66729825e39c..eeef46381732e8 100644
--- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
+++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
@@ -6,37 +6,56 @@
* Public License, v 1.
*/
+import {
+ elasticsearchClientMock,
+ MockedTransportRequestPromise,
+ // eslint-disable-next-line @kbn/eslint/no-restricted-paths
+} from '../../../../../core/server/elasticsearch/client/mocks';
import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks';
import { esSearchStrategyProvider } from './es_search_strategy';
import { SearchStrategyDependencies } from '../types';
+import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json';
+import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors';
+import { KbnServerError } from '../../../../kibana_utils/server';
+
describe('ES search strategy', () => {
+ const successBody = {
+ _shards: {
+ total: 10,
+ failed: 1,
+ skipped: 2,
+ successful: 7,
+ },
+ };
+ let mockedApiCaller: MockedTransportRequestPromise;
+ let mockApiCaller: jest.Mock<() => MockedTransportRequestPromise>;
const mockLogger: any = {
debug: () => {},
};
- const mockApiCaller = jest.fn().mockResolvedValue({
- body: {
- _shards: {
- total: 10,
- failed: 1,
- skipped: 2,
- successful: 7,
- },
- },
- });
- const mockDeps = ({
- uiSettingsClient: {
- get: () => {},
- },
- esClient: { asCurrentUser: { search: mockApiCaller } },
- } as unknown) as SearchStrategyDependencies;
+ function getMockedDeps(err?: Record) {
+ mockApiCaller = jest.fn().mockImplementation(() => {
+ if (err) {
+ mockedApiCaller = elasticsearchClientMock.createErrorTransportRequestPromise(err);
+ } else {
+ mockedApiCaller = elasticsearchClientMock.createSuccessTransportRequestPromise(
+ successBody,
+ { statusCode: 200 }
+ );
+ }
+ return mockedApiCaller;
+ });
- const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$;
+ return ({
+ uiSettingsClient: {
+ get: () => {},
+ },
+ esClient: { asCurrentUser: { search: mockApiCaller } },
+ } as unknown) as SearchStrategyDependencies;
+ }
- beforeEach(() => {
- mockApiCaller.mockClear();
- });
+ const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$;
it('returns a strategy with `search`', async () => {
const esSearch = await esSearchStrategyProvider(mockConfig$, mockLogger);
@@ -48,7 +67,7 @@ describe('ES search strategy', () => {
const params = { index: 'logstash-*' };
await esSearchStrategyProvider(mockConfig$, mockLogger)
- .search({ params }, {}, mockDeps)
+ .search({ params }, {}, getMockedDeps())
.subscribe(() => {
expect(mockApiCaller).toBeCalled();
expect(mockApiCaller.mock.calls[0][0]).toEqual({
@@ -64,7 +83,7 @@ describe('ES search strategy', () => {
const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
await esSearchStrategyProvider(mockConfig$, mockLogger)
- .search({ params }, {}, mockDeps)
+ .search({ params }, {}, getMockedDeps())
.subscribe(() => {
expect(mockApiCaller).toBeCalled();
expect(mockApiCaller.mock.calls[0][0]).toEqual({
@@ -82,13 +101,109 @@ describe('ES search strategy', () => {
params: { index: 'logstash-*' },
},
{},
- mockDeps
+ getMockedDeps()
)
.subscribe((data) => {
expect(data.isRunning).toBe(false);
expect(data.isPartial).toBe(false);
expect(data).toHaveProperty('loaded');
expect(data).toHaveProperty('rawResponse');
+ expect(mockedApiCaller.abort).not.toBeCalled();
done();
}));
+
+ it('can be aborted', async () => {
+ const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
+
+ const abortController = new AbortController();
+ abortController.abort();
+
+ await esSearchStrategyProvider(mockConfig$, mockLogger)
+ .search({ params }, { abortSignal: abortController.signal }, getMockedDeps())
+ .toPromise();
+
+ expect(mockApiCaller).toBeCalled();
+ expect(mockApiCaller.mock.calls[0][0]).toEqual({
+ ...params,
+ track_total_hits: true,
+ });
+ expect(mockedApiCaller.abort).toBeCalled();
+ });
+
+ it('throws normalized error if ResponseError is thrown', async (done) => {
+ const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
+ const errResponse = new ResponseError({
+ body: indexNotFoundException,
+ statusCode: 404,
+ headers: {},
+ warnings: [],
+ meta: {} as any,
+ });
+
+ try {
+ await esSearchStrategyProvider(mockConfig$, mockLogger)
+ .search({ params }, {}, getMockedDeps(errResponse))
+ .toPromise();
+ } catch (e) {
+ expect(mockApiCaller).toBeCalled();
+ expect(e).toBeInstanceOf(KbnServerError);
+ expect(e.statusCode).toBe(404);
+ expect(e.message).toBe(errResponse.message);
+ expect(e.errBody).toBe(indexNotFoundException);
+ done();
+ }
+ });
+
+ it('throws normalized error if ElasticsearchClientError is thrown', async (done) => {
+ const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
+ const errResponse = new ElasticsearchClientError('This is a general ESClient error');
+
+ try {
+ await esSearchStrategyProvider(mockConfig$, mockLogger)
+ .search({ params }, {}, getMockedDeps(errResponse))
+ .toPromise();
+ } catch (e) {
+ expect(mockApiCaller).toBeCalled();
+ expect(e).toBeInstanceOf(KbnServerError);
+ expect(e.statusCode).toBe(500);
+ expect(e.message).toBe(errResponse.message);
+ expect(e.errBody).toBe(undefined);
+ done();
+ }
+ });
+
+ it('throws normalized error if ESClient throws unknown error', async (done) => {
+ const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
+ const errResponse = new Error('ESClient error');
+
+ try {
+ await esSearchStrategyProvider(mockConfig$, mockLogger)
+ .search({ params }, {}, getMockedDeps(errResponse))
+ .toPromise();
+ } catch (e) {
+ expect(mockApiCaller).toBeCalled();
+ expect(e).toBeInstanceOf(KbnServerError);
+ expect(e.statusCode).toBe(500);
+ expect(e.message).toBe(errResponse.message);
+ expect(e.errBody).toBe(undefined);
+ done();
+ }
+ });
+
+ it('throws KbnServerError for unknown index type', async (done) => {
+ const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
+
+ try {
+ await esSearchStrategyProvider(mockConfig$, mockLogger)
+ .search({ indexType: 'banana', params }, {}, getMockedDeps())
+ .toPromise();
+ } catch (e) {
+ expect(mockApiCaller).not.toBeCalled();
+ expect(e).toBeInstanceOf(KbnServerError);
+ expect(e.message).toBe('Unsupported index pattern type banana');
+ expect(e.statusCode).toBe(400);
+ expect(e.errBody).toBe(undefined);
+ done();
+ }
+ });
});
diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts
index 55850f5cc45a34..2d9b16ac8b00bf 100644
--- a/src/plugins/data/server/search/es_search/es_search_strategy.ts
+++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts
@@ -15,13 +15,20 @@ import type { SearchUsage } from '../collectors';
import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils';
import { shimHitsTotal, toKibanaSearchResponse } from './response_utils';
import { searchUsageObserver } from '../collectors/usage';
-import { KbnServerError } from '../../../../kibana_utils/server';
+import { getKbnServerError, KbnServerError } from '../../../../kibana_utils/server';
export const esSearchStrategyProvider = (
config$: Observable,
logger: Logger,
usage?: SearchUsage
): ISearchStrategy => ({
+ /**
+ * @param request
+ * @param options
+ * @param deps
+ * @throws `KbnServerError`
+ * @returns `Observable>`
+ */
search: (request, { abortSignal, ...options }, { esClient, uiSettingsClient }) => {
// Only default index pattern type is supported here.
// See data_enhanced for other type support.
@@ -30,16 +37,20 @@ export const esSearchStrategyProvider = (
}
const search = async () => {
- const config = await config$.pipe(first()).toPromise();
- const params = {
- ...(await getDefaultSearchParams(uiSettingsClient)),
- ...getShardTimeout(config),
- ...request.params,
- };
- const promise = esClient.asCurrentUser.search>(params);
- const { body } = await shimAbortSignal(promise, abortSignal);
- const response = shimHitsTotal(body, options);
- return toKibanaSearchResponse(response);
+ try {
+ const config = await config$.pipe(first()).toPromise();
+ const params = {
+ ...(await getDefaultSearchParams(uiSettingsClient)),
+ ...getShardTimeout(config),
+ ...request.params,
+ };
+ const promise = esClient.asCurrentUser.search>(params);
+ const { body } = await shimAbortSignal(promise, abortSignal);
+ const response = shimHitsTotal(body, options);
+ return toKibanaSearchResponse(response);
+ } catch (e) {
+ throw getKbnServerError(e);
+ }
};
return from(search()).pipe(tap(searchUsageObserver(logger, usage)));
diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts
new file mode 100644
index 00000000000000..ba96726b787c02
--- /dev/null
+++ b/src/plugins/data/server/search/routes/bsearch.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { catchError, first } from 'rxjs/operators';
+import { CoreStart, KibanaRequest } from 'src/core/server';
+import { BfetchServerSetup } from 'src/plugins/bfetch/server';
+import {
+ IKibanaSearchRequest,
+ IKibanaSearchResponse,
+ ISearchClient,
+ ISearchOptions,
+} from '../../../common/search';
+
+type GetScopedProider = (coreStart: CoreStart) => (request: KibanaRequest) => ISearchClient;
+
+export function registerBsearchRoute(
+ bfetch: BfetchServerSetup,
+ coreStartPromise: Promise<[CoreStart, {}, {}]>,
+ getScopedProvider: GetScopedProider
+): void {
+ bfetch.addBatchProcessingRoute<
+ { request: IKibanaSearchRequest; options?: ISearchOptions },
+ IKibanaSearchResponse
+ >('/internal/bsearch', (request) => {
+ return {
+ /**
+ * @param requestOptions
+ * @throws `KibanaServerError`
+ */
+ onBatchItem: async ({ request: requestData, options }) => {
+ const coreStart = await coreStartPromise;
+ const search = getScopedProvider(coreStart[0])(request);
+ return search
+ .search(requestData, options)
+ .pipe(
+ first(),
+ catchError((err) => {
+ // Re-throw as object, to get attributes passed to the client
+ // eslint-disable-next-line no-throw-literal
+ throw {
+ message: err.message,
+ statusCode: err.statusCode,
+ attributes: err.errBody?.error,
+ };
+ })
+ )
+ .toPromise();
+ },
+ };
+ });
+}
diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts
index 689031cbd402c1..e6ff5f454079b2 100644
--- a/src/plugins/data/server/search/routes/call_msearch.ts
+++ b/src/plugins/data/server/search/routes/call_msearch.ts
@@ -8,11 +8,11 @@
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';
-import { ApiResponse } from '@elastic/elasticsearch';
import { SearchResponse } from 'elasticsearch';
import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server';
import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source';
+import { getKbnServerError } from '../../../../kibana_utils/server';
import { getShardTimeout, getDefaultSearchParams, shimAbortSignal, shimHitsTotal } from '..';
/** @internal */
@@ -47,6 +47,9 @@ interface CallMsearchDependencies {
* @internal
*/
export function getCallMsearch(dependencies: CallMsearchDependencies) {
+ /**
+ * @throws KbnServerError
+ */
return async (params: {
body: MsearchRequestBody;
signal?: AbortSignal;
@@ -60,28 +63,29 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) {
// trackTotalHits is not supported by msearch
const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings);
- const body = convertRequestBody(params.body, timeout);
-
- const promise = shimAbortSignal(
- esClient.asCurrentUser.msearch(
+ try {
+ const promise = esClient.asCurrentUser.msearch(
{
- body,
+ body: convertRequestBody(params.body, timeout),
},
{
querystring: defaultParams,
}
- ),
- params.signal
- );
- const response = (await promise) as ApiResponse<{ responses: Array> }>;
+ );
+ const response = await shimAbortSignal(promise, params.signal);
- return {
- body: {
- ...response,
+ return {
body: {
- responses: response.body.responses?.map((r: SearchResponse) => shimHitsTotal(r)),
+ ...response,
+ body: {
+ responses: response.body.responses?.map((r: SearchResponse) =>
+ shimHitsTotal(r)
+ ),
+ },
},
- },
- };
+ };
+ } catch (e) {
+ throw getKbnServerError(e);
+ }
};
}
diff --git a/src/plugins/data/server/search/routes/msearch.test.ts b/src/plugins/data/server/search/routes/msearch.test.ts
index 02f200d5435dda..a847931a49123d 100644
--- a/src/plugins/data/server/search/routes/msearch.test.ts
+++ b/src/plugins/data/server/search/routes/msearch.test.ts
@@ -24,6 +24,8 @@ import { convertRequestBody } from './call_msearch';
import { registerMsearchRoute } from './msearch';
import { DataPluginStart } from '../../plugin';
import { dataPluginMock } from '../../mocks';
+import * as jsonEofException from '../../../common/search/test_data/json_e_o_f_exception.json';
+import { ResponseError } from '@elastic/elasticsearch/lib/errors';
describe('msearch route', () => {
let mockDataStart: MockedKeys;
@@ -76,15 +78,52 @@ describe('msearch route', () => {
});
});
- it('handler throws an error if the search throws an error', async () => {
- const response = {
- message: 'oh no',
- body: {
- error: 'oops',
+ it('handler returns an error response if the search throws an error', async () => {
+ const rejectedValue = Promise.reject(
+ new ResponseError({
+ body: jsonEofException,
+ statusCode: 400,
+ meta: {} as any,
+ headers: [],
+ warnings: [],
+ })
+ );
+ const mockClient = {
+ msearch: jest.fn().mockReturnValue(rejectedValue),
+ };
+ const mockContext = {
+ core: {
+ elasticsearch: { client: { asCurrentUser: mockClient } },
+ uiSettings: { client: { get: jest.fn() } },
},
};
+ const mockBody = { searches: [{ header: {}, body: {} }] };
+ const mockQuery = {};
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: mockBody,
+ query: mockQuery,
+ });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ registerMsearchRoute(mockCoreSetup.http.createRouter(), { getStartServices, globalConfig$ });
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const handler = mockRouter.post.mock.calls[0][1];
+ await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
+
+ expect(mockClient.msearch).toBeCalledTimes(1);
+ expect(mockResponse.customError).toBeCalled();
+
+ const error: any = mockResponse.customError.mock.calls[0][0];
+ expect(error.statusCode).toBe(400);
+ expect(error.body.message).toBe('json_e_o_f_exception');
+ expect(error.body.attributes).toBe(jsonEofException.error);
+ });
+
+ it('handler returns an error response if the search throws a general error', async () => {
+ const rejectedValue = Promise.reject(new Error('What happened?'));
const mockClient = {
- msearch: jest.fn().mockReturnValue(Promise.reject(response)),
+ msearch: jest.fn().mockReturnValue(rejectedValue),
};
const mockContext = {
core: {
@@ -106,11 +145,12 @@ describe('msearch route', () => {
const handler = mockRouter.post.mock.calls[0][1];
await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
- expect(mockClient.msearch).toBeCalled();
+ expect(mockClient.msearch).toBeCalledTimes(1);
expect(mockResponse.customError).toBeCalled();
const error: any = mockResponse.customError.mock.calls[0][0];
- expect(error.body.message).toBe('oh no');
- expect(error.body.attributes.error).toBe('oops');
+ expect(error.statusCode).toBe(500);
+ expect(error.body.message).toBe('What happened?');
+ expect(error.body.attributes).toBe(undefined);
});
});
diff --git a/src/plugins/data/server/search/routes/search.test.ts b/src/plugins/data/server/search/routes/search.test.ts
index f47a42cf9d82b4..2cde6d19e4c187 100644
--- a/src/plugins/data/server/search/routes/search.test.ts
+++ b/src/plugins/data/server/search/routes/search.test.ts
@@ -12,11 +12,27 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server';
import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks';
import { registerSearchRoute } from './search';
import { DataPluginStart } from '../../plugin';
+import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json';
+import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json';
+import { KbnServerError } from '../../../../kibana_utils/server';
describe('Search service', () => {
let mockCoreSetup: MockedKeys>;
+ function mockEsError(message: string, statusCode: number, attributes?: Record) {
+ return new KbnServerError(message, statusCode, attributes);
+ }
+
+ async function runMockSearch(mockContext: any, mockRequest: any, mockResponse: any) {
+ registerSearchRoute(mockCoreSetup.http.createRouter());
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const handler = mockRouter.post.mock.calls[0][1];
+ await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
+ }
+
beforeEach(() => {
+ jest.clearAllMocks();
mockCoreSetup = coreMock.createSetup();
});
@@ -54,11 +70,7 @@ describe('Search service', () => {
});
const mockResponse = httpServerMock.createResponseFactory();
- registerSearchRoute(mockCoreSetup.http.createRouter());
-
- const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
- const handler = mockRouter.post.mock.calls[0][1];
- await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
+ await runMockSearch(mockContext, mockRequest, mockResponse);
expect(mockContext.search.search).toBeCalled();
expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody);
@@ -68,14 +80,9 @@ describe('Search service', () => {
});
});
- it('handler throws an error if the search throws an error', async () => {
+ it('handler returns an error response if the search throws a painless error', async () => {
const rejectedValue = from(
- Promise.reject({
- message: 'oh no',
- body: {
- error: 'oops',
- },
- })
+ Promise.reject(mockEsError('search_phase_execution_exception', 400, searchPhaseException))
);
const mockContext = {
@@ -84,25 +91,69 @@ describe('Search service', () => {
},
};
- const mockBody = { id: undefined, params: {} };
- const mockParams = { strategy: 'foo' };
const mockRequest = httpServerMock.createKibanaRequest({
- body: mockBody,
- params: mockParams,
+ body: { id: undefined, params: {} },
+ params: { strategy: 'foo' },
});
const mockResponse = httpServerMock.createResponseFactory();
- registerSearchRoute(mockCoreSetup.http.createRouter());
+ await runMockSearch(mockContext, mockRequest, mockResponse);
- const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
- const handler = mockRouter.post.mock.calls[0][1];
- await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
+ // verify error
+ expect(mockResponse.customError).toBeCalled();
+ const error: any = mockResponse.customError.mock.calls[0][0];
+ expect(error.statusCode).toBe(400);
+ expect(error.body.message).toBe('search_phase_execution_exception');
+ expect(error.body.attributes).toBe(searchPhaseException.error);
+ });
+
+ it('handler returns an error response if the search throws an index not found error', async () => {
+ const rejectedValue = from(
+ Promise.reject(mockEsError('index_not_found_exception', 404, indexNotFoundException))
+ );
+
+ const mockContext = {
+ search: {
+ search: jest.fn().mockReturnValue(rejectedValue),
+ },
+ };
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { id: undefined, params: {} },
+ params: { strategy: 'foo' },
+ });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ await runMockSearch(mockContext, mockRequest, mockResponse);
+
+ expect(mockResponse.customError).toBeCalled();
+ const error: any = mockResponse.customError.mock.calls[0][0];
+ expect(error.statusCode).toBe(404);
+ expect(error.body.message).toBe('index_not_found_exception');
+ expect(error.body.attributes).toBe(indexNotFoundException.error);
+ });
+
+ it('handler returns an error response if the search throws a general error', async () => {
+ const rejectedValue = from(Promise.reject(new Error('This is odd')));
+
+ const mockContext = {
+ search: {
+ search: jest.fn().mockReturnValue(rejectedValue),
+ },
+ };
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { id: undefined, params: {} },
+ params: { strategy: 'foo' },
+ });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ await runMockSearch(mockContext, mockRequest, mockResponse);
- expect(mockContext.search.search).toBeCalled();
- expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody);
expect(mockResponse.customError).toBeCalled();
const error: any = mockResponse.customError.mock.calls[0][0];
- expect(error.body.message).toBe('oh no');
- expect(error.body.attributes.error).toBe('oops');
+ expect(error.statusCode).toBe(500);
+ expect(error.body.message).toBe('This is odd');
+ expect(error.body.attributes).toBe(undefined);
});
});
diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts
index 3767467d4d0cf5..e9f0edbd4d6c47 100644
--- a/src/plugins/data/server/search/search_service.ts
+++ b/src/plugins/data/server/search/search_service.ts
@@ -6,7 +6,7 @@
* Public License, v 1.
*/
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { pick } from 'lodash';
import {
CoreSetup,
@@ -18,7 +18,7 @@ import {
SharedGlobalConfig,
StartServicesAccessor,
} from 'src/core/server';
-import { catchError, first } from 'rxjs/operators';
+import { first } from 'rxjs/operators';
import { BfetchServerSetup } from 'src/plugins/bfetch/server';
import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import type {
@@ -64,6 +64,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn';
import { ConfigSchema } from '../../config';
import { IScopedSessionService, ISessionService, SessionService } from './session';
import { KbnServerError } from '../../../kibana_utils/server';
+import { registerBsearchRoute } from './routes/bsearch';
type StrategyMap = Record>;
@@ -137,35 +138,7 @@ export class SearchService implements Plugin {
)
);
- bfetch.addBatchProcessingRoute<
- { request: IKibanaSearchResponse; options?: ISearchOptions },
- any
- >('/internal/bsearch', (request) => {
- const search = this.asScopedProvider(this.coreStart!)(request);
-
- return {
- onBatchItem: async ({ request: requestData, options }) => {
- return search
- .search(requestData, options)
- .pipe(
- first(),
- catchError((err) => {
- // eslint-disable-next-line no-throw-literal
- throw {
- statusCode: err.statusCode || 500,
- body: {
- message: err.message,
- attributes: {
- error: err.body?.error || err.message,
- },
- },
- };
- })
- )
- .toPromise();
- },
- };
- });
+ registerBsearchRoute(bfetch, core.getStartServices(), this.asScopedProvider);
core.savedObjects.registerType(searchTelemetry);
if (usageCollection) {
@@ -277,10 +250,14 @@ export class SearchService implements Plugin {
options: ISearchOptions,
deps: SearchStrategyDependencies
) => {
- const strategy = this.getSearchStrategy(
- options.strategy
- );
- return session.search(strategy, request, options, deps);
+ try {
+ const strategy = this.getSearchStrategy(
+ options.strategy
+ );
+ return session.search(strategy, request, options, deps);
+ } catch (e) {
+ return throwError(e);
+ }
};
private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => {
diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json
index 81bcb3b02e100e..21560b13288407 100644
--- a/src/plugins/data/tsconfig.json
+++ b/src/plugins/data/tsconfig.json
@@ -7,7 +7,7 @@
"declaration": true,
"declarationMap": true
},
- "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"],
+ "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"],
"references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../bfetch/tsconfig.json" },
diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts
index 1b7406496bb813..4d522f47ea87ff 100644
--- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts
+++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts
@@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import { showOpenSearchPanel } from './show_open_search_panel';
-import { getSharingData } from '../../helpers/get_sharing_data';
+import { getSharingData, showPublicUrlSwitch } from '../../helpers/get_sharing_data';
import { unhashUrl } from '../../../../../kibana_utils/public';
import { DiscoverServices } from '../../../build_services';
import { Adapters } from '../../../../../inspector/common/adapters';
@@ -108,6 +108,7 @@ export const getTopNavLinks = ({
title: savedSearch.title,
},
isDirty: !savedSearch.id || state.isAppStateDirty(),
+ showPublicUrlSwitch,
});
},
};
diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts
index 1394ceab1dd18e..ea16b81615e424 100644
--- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts
+++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts
@@ -6,7 +6,8 @@
* Public License, v 1.
*/
-import { getSharingData } from './get_sharing_data';
+import { Capabilities } from 'kibana/public';
+import { getSharingData, showPublicUrlSwitch } from './get_sharing_data';
import { IUiSettingsClient } from 'kibana/public';
import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks';
import { indexPatternMock } from '../../__mocks__/index_pattern';
@@ -68,3 +69,44 @@ describe('getSharingData', () => {
`);
});
});
+
+describe('showPublicUrlSwitch', () => {
+ test('returns false if "discover" app is not available', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(false);
+ });
+
+ test('returns false if "discover" app is not accessible', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ discover: {
+ show: false,
+ },
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(false);
+ });
+
+ test('returns true if "discover" app is not available an accessible', () => {
+ const anonymousUserCapabilities: Capabilities = {
+ catalogue: {},
+ management: {},
+ navLinks: {},
+ discover: {
+ show: true,
+ },
+ };
+ const result = showPublicUrlSwitch(anonymousUserCapabilities);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts
index 62478f1d2830f8..1d780a5573e2a7 100644
--- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts
+++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts
@@ -6,7 +6,7 @@
* Public License, v 1.
*/
-import { IUiSettingsClient } from 'kibana/public';
+import { Capabilities, IUiSettingsClient } from 'kibana/public';
import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common';
import { getSortForSearchSource } from '../angular/doc_table';
import { SearchSource } from '../../../../data/common';
@@ -76,3 +76,19 @@ export async function getSharingData(
indexPatternId: index.id,
};
}
+
+export interface DiscoverCapabilities {
+ createShortUrl?: boolean;
+ save?: boolean;
+ saveQuery?: boolean;
+ show?: boolean;
+ storeSearchSession?: boolean;
+}
+
+export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => {
+ if (!anonymousUserCapabilities.discover) return false;
+
+ const discover = (anonymousUserCapabilities.discover as unknown) as DiscoverCapabilities;
+
+ return !!discover.show;
+};
diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json
new file mode 100644
index 00000000000000..bef7bc394a6ccd
--- /dev/null
+++ b/src/plugins/input_control_vis/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./target/types",
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": [
+ "public/**/*",
+ "server/**/*",
+ ],
+ "references": [
+ { "path": "../kibana_react/tsconfig.json" },
+ { "path": "../data/tsconfig.json"},
+ { "path": "../expressions/tsconfig.json" },
+ { "path": "../visualizations/tsconfig.json" },
+ { "path": "../vis_default_editor/tsconfig.json" },
+ ]
+}
diff --git a/src/plugins/kibana_utils/common/errors/index.ts b/src/plugins/kibana_utils/common/errors/index.ts
index 354cf1d504b287..f859e0728269a1 100644
--- a/src/plugins/kibana_utils/common/errors/index.ts
+++ b/src/plugins/kibana_utils/common/errors/index.ts
@@ -7,3 +7,4 @@
*/
export * from './errors';
+export * from './types';
diff --git a/src/plugins/kibana_utils/common/errors/types.ts b/src/plugins/kibana_utils/common/errors/types.ts
new file mode 100644
index 00000000000000..89e83586dc1157
--- /dev/null
+++ b/src/plugins/kibana_utils/common/errors/types.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+export interface KibanaServerError {
+ statusCode: number;
+ message: string;
+ attributes?: T;
+}
diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts
index f95ffe5c3d7b6f..821118ea4640dd 100644
--- a/src/plugins/kibana_utils/server/index.ts
+++ b/src/plugins/kibana_utils/server/index.ts
@@ -18,4 +18,4 @@ export {
url,
} from '../common';
-export { KbnServerError, reportServerError } from './report_server_error';
+export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error';
diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts
index 664f34ca7ad518..01e80cfc7184d3 100644
--- a/src/plugins/kibana_utils/server/report_server_error.ts
+++ b/src/plugins/kibana_utils/server/report_server_error.ts
@@ -6,23 +6,42 @@
* Public License, v 1.
*/
+import { ResponseError } from '@elastic/elasticsearch/lib/errors';
import { KibanaResponseFactory } from 'kibana/server';
import { KbnError } from '../common';
export class KbnServerError extends KbnError {
- constructor(message: string, public readonly statusCode: number) {
+ public errBody?: Record;
+ constructor(message: string, public readonly statusCode: number, errBody?: Record) {
super(message);
+ this.errBody = errBody;
}
}
-export function reportServerError(res: KibanaResponseFactory, err: any) {
+/**
+ * Formats any error thrown into a standardized `KbnServerError`.
+ * @param e `Error` or `ElasticsearchClientError`
+ * @returns `KbnServerError`
+ */
+export function getKbnServerError(e: Error) {
+ return new KbnServerError(
+ e.message ?? 'Unknown error',
+ e instanceof ResponseError ? e.statusCode : 500,
+ e instanceof ResponseError ? e.body : undefined
+ );
+}
+
+/**
+ *
+ * @param res Formats a `KbnServerError` into a server error response
+ * @param err
+ */
+export function reportServerError(res: KibanaResponseFactory, err: KbnServerError) {
return res.customError({
statusCode: err.statusCode ?? 500,
body: {
message: err.message,
- attributes: {
- error: err.body?.error || err.message,
- },
+ attributes: err.errBody?.error,
},
});
}
diff --git a/src/plugins/legacy_export/tsconfig.json b/src/plugins/legacy_export/tsconfig.json
new file mode 100644
index 00000000000000..ec006d492499ea
--- /dev/null
+++ b/src/plugins/legacy_export/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./target/types",
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": [
+ "server/**/*",
+ ],
+ "references": [
+ { "path": "../../core/tsconfig.json" }
+ ]
+}
diff --git a/src/plugins/presentation_util/README.md b/src/plugins/presentation_util/README.md
deleted file mode 100755
index 047423a0a90369..00000000000000
--- a/src/plugins/presentation_util/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# presentationUtil
-
-Utilities and components used by the presentation-related plugins
\ No newline at end of file
diff --git a/src/plugins/presentation_util/README.mdx b/src/plugins/presentation_util/README.mdx
new file mode 100755
index 00000000000000..35b80e36345345
--- /dev/null
+++ b/src/plugins/presentation_util/README.mdx
@@ -0,0 +1,211 @@
+---
+id: presentationUtilPlugin
+slug: /kibana-dev-docs/presentationPlugin
+title: Presentation Utility Plugin
+summary: Introduction to the Presentation Utility Plugin.
+date: 2020-01-12
+tags: ['kibana', 'presentation', 'services']
+related: []
+---
+
+## Introduction
+
+The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas).
+
+## Plugin Services Toolkit
+
+While Kibana provides a `useKibana` hook for use in a plugin, the number of services it provides is very large. This presents a set of difficulties:
+
+- a direct dependency upon the Kibana environment;
+- a requirement to mock the full Kibana environment when testing or using Storybook;
+- a lack of knowledge as to what services are being consumed at any given time.
+
+To mitigate these difficulties, the Presentation Team creates services within the plugin that then consume Kibana-provided (or other) services. This is a toolkit for creating simple services within a plugin.
+
+### Overview
+
+- A `PluginServiceFactory` is a function that will return a set of functions-- which comprise a `Service`-- given a set of parameters.
+- A `PluginServiceProvider` is an object that use a factory to start, stop or provide a `Service`.
+- A `PluginServiceRegistry` is a collection of providers for a given environment, (e.g. Kibana, Jest, Storybook, stub, etc).
+- A `PluginServices` object uses a registry to provide services throughout the plugin.
+
+### Defining Services
+
+To start, a plugin should define a set of services it wants to provide to itself or other plugins.
+
+
+```ts
+export interface PresentationDashboardsService {
+ findDashboards: (
+ query: string,
+ fields: string[]
+ ) => Promise>>;
+ findDashboardsByTitle: (title: string) => Promise>>;
+}
+
+export interface PresentationFooService {
+ getFoo: () => string;
+ setFoo: (bar: string) => void;
+}
+
+export interface PresentationUtilServices {
+ dashboards: PresentationDashboardsService;
+ foo: PresentationFooService;
+}
+```
+
+
+This definition will be used in the toolkit to ensure services are complete and as expected.
+
+### Plugin Services
+
+The `PluginServices` class hosts a registry of service providers from which a plugin can access its services. It uses the service definition as a generic.
+
+```ts
+export const pluginServices = new PluginServices();
+```
+
+This can be placed in the `index.ts` file of a `services` directory within your plugin.
+
+Once created, it simply requires a `PluginServiceRegistry` to be started and set.
+
+### Service Provider Registry
+
+Each environment in which components are used requires a `PluginServiceRegistry` to specify how the providers are started. For example, simple stubs of services require no parameters to start, (so the `StartParameters` generic remains unspecified)
+
+
+```ts
+export const providers: PluginServiceProviders = {
+ dashboards: new PluginServiceProvider(dashboardsServiceFactory),
+ foo: new PluginServiceProvider(fooServiceFactory),
+};
+
+export const serviceRegistry = new PluginServiceRegistry(providers);
+```
+
+
+By contrast, a registry that uses Kibana can provide `KibanaPluginServiceParams` to determine how to start its providers, so the `StartParameters` generic is given:
+
+
+```ts
+export const providers: PluginServiceProviders<
+ PresentationUtilServices,
+ KibanaPluginServiceParams
+> = {
+ dashboards: new PluginServiceProvider(dashboardsServiceFactory),
+ foo: new PluginServiceProvider(fooServiceFactory),
+};
+
+export const serviceRegistry = new PluginServiceRegistry<
+ PresentationUtilServices,
+ KibanaPluginServiceParams
+>(providers);
+```
+
+
+### Service Provider
+
+A `PluginServiceProvider` is a container for a Service Factory that is responsible for starting, stopping and providing a service implementation. A Service Provider doesn't change, rather the factory and the relevant `StartParameters` change.
+
+### Service Factories
+
+A Service Factory is nothing more than a function that uses `StartParameters` to return a set of functions that conforms to a portion of the `Services` specification. For each service, a factory is provided for each environment.
+
+Given a service definition:
+
+```ts
+export interface PresentationFooService {
+ getFoo: () => string;
+ setFoo: (bar: string) => void;
+}
+```
+
+a factory for a stubbed version might look like this:
+
+```ts
+type FooServiceFactory = PluginServiceFactory;
+
+export const fooServiceFactory: FooServiceFactory = () => ({
+ getFoo: () => 'bar',
+ setFoo: (bar) => { console.log(`${bar} set!`)},
+});
+```
+
+and a factory for a Kibana version might look like this:
+
+```ts
+export type FooServiceFactory = KibanaPluginServiceFactory<
+ PresentationFooService,
+ PresentationUtilPluginStart
+>;
+
+export const fooServiceFactory: FooServiceFactory = ({
+ coreStart,
+ startPlugins,
+}) => {
+ // ...do something with Kibana services...
+
+ return {
+ getFoo: //...
+ setFoo: //...
+ }
+}
+```
+
+### Using Services
+
+Once your services and providers are defined, and you have at least one set of factories, you can use `PluginServices` to provide the services to your React components:
+
+
+```ts
+// plugin.ts
+import { pluginServices } from './services';
+import { registry } from './services/kibana';
+
+ public async start(
+ coreStart: CoreStart,
+ startPlugins: StartDeps
+ ): Promise {
+ pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
+ return {};
+ }
+```
+
+
+and wrap your root React component with the `PluginServices` context:
+
+
+```ts
+import { pluginServices } from './services';
+
+const ContextProvider = pluginServices.getContextProvider(),
+
+return(
+
+
+ {application}
+
+
+)
+```
+
+
+and then, consume your services using provided hooks in a component:
+
+
+```ts
+// component.ts
+
+import { pluginServices } from '../services';
+
+export function MyComponent() {
+ // Retrieve all context hooks from `PluginServices`, destructuring for the one we're using
+ const { foo } = pluginServices.getHooks();
+
+ // Use the `useContext` hook to access the API.
+ const { getFoo } = foo.useService();
+
+ // ...
+}
+```
+
diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx
new file mode 100644
index 00000000000000..cb9991e2160197
--- /dev/null
+++ b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React from 'react';
+import { action } from '@storybook/addon-actions';
+
+import { DashboardPicker } from './dashboard_picker';
+
+export default {
+ component: DashboardPicker,
+ title: 'Dashboard Picker',
+ argTypes: {
+ isDisabled: {
+ control: 'boolean',
+ defaultValue: false,
+ },
+ },
+};
+
+export const Example = ({ isDisabled }: { isDisabled: boolean }) => (
+
+);
diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx
index 8aaf9be6ef5c66..b156ef4ae764c7 100644
--- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx
+++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx
@@ -6,18 +6,16 @@
* Public License, v 1.
*/
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiComboBox } from '@elastic/eui';
-import { SavedObjectsClientContract } from '../../../../core/public';
-import { DashboardSavedObject } from '../../../../plugins/dashboard/public';
+import { pluginServices } from '../services';
export interface DashboardPickerProps {
onChange: (dashboard: { name: string; id: string } | null) => void;
isDisabled: boolean;
- savedObjectsClient: SavedObjectsClientContract;
}
interface DashboardOption {
@@ -26,34 +24,43 @@ interface DashboardOption {
}
export function DashboardPicker(props: DashboardPickerProps) {
- const [dashboards, setDashboards] = useState([]);
+ const [dashboardOptions, setDashboardOptions] = useState([]);
const [isLoadingDashboards, setIsLoadingDashboards] = useState(true);
const [selectedDashboard, setSelectedDashboard] = useState(null);
+ const [query, setQuery] = useState('');
- const { savedObjectsClient, isDisabled, onChange } = props;
+ const { isDisabled, onChange } = props;
+ const { dashboards } = pluginServices.getHooks();
+ const { findDashboardsByTitle } = dashboards.useService();
- const fetchDashboards = useCallback(
- async (query) => {
+ useEffect(() => {
+ // We don't want to manipulate the React state if the component has been unmounted
+ // while we wait for the saved objects to return.
+ let cleanedUp = false;
+
+ const fetchDashboards = async () => {
setIsLoadingDashboards(true);
- setDashboards([]);
-
- const { savedObjects } = await savedObjectsClient.find({
- type: 'dashboard',
- search: query ? `${query}*` : '',
- searchFields: ['title'],
- });
- if (savedObjects) {
- setDashboards(savedObjects.map((d) => ({ value: d.id, label: d.attributes.title })));
+ setDashboardOptions([]);
+
+ const objects = await findDashboardsByTitle(query ? `${query}*` : '');
+
+ if (cleanedUp) {
+ return;
+ }
+
+ if (objects) {
+ setDashboardOptions(objects.map((d) => ({ value: d.id, label: d.attributes.title })));
}
+
setIsLoadingDashboards(false);
- },
- [savedObjectsClient]
- );
+ };
- // Initial dashboard load
- useEffect(() => {
- fetchDashboards('');
- }, [fetchDashboards]);
+ fetchDashboards();
+
+ return () => {
+ cleanedUp = true;
+ };
+ }, [findDashboardsByTitle, query]);
return (
{
if (e.length) {
@@ -72,7 +79,7 @@ export function DashboardPicker(props: DashboardPickerProps) {
onChange(null);
}
}}
- onSearchChange={fetchDashboards}
+ onSearchChange={setQuery}
isDisabled={isDisabled}
isLoading={isLoadingDashboards}
compressed={true}
diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx
index 58a70c9db7dd5c..7c7b12f52ab5f1 100644
--- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx
+++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx
@@ -9,18 +9,6 @@
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n/react';
-
-import {
- EuiFlexGroup,
- EuiFlexItem,
- EuiFormRow,
- EuiRadio,
- EuiIconTip,
- EuiPanel,
- EuiSpacer,
-} from '@elastic/eui';
-import { SavedObjectsClientContract } from '../../../../core/public';
import {
OnSaveProps,
@@ -28,9 +16,9 @@ import {
SavedObjectSaveModal,
} from '../../../../plugins/saved_objects/public';
-import { DashboardPicker } from './dashboard_picker';
-
import './saved_object_save_modal_dashboard.scss';
+import { pluginServices } from '../services';
+import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector';
interface SaveModalDocumentInfo {
id?: string;
@@ -38,116 +26,50 @@ interface SaveModalDocumentInfo {
description?: string;
}
-export interface DashboardSaveModalProps {
+export interface SaveModalDashboardProps {
documentInfo: SaveModalDocumentInfo;
objectType: string;
onClose: () => void;
onSave: (props: OnSaveProps & { dashboardId: string | null }) => void;
- savedObjectsClient: SavedObjectsClientContract;
tagOptions?: React.ReactNode | ((state: SaveModalState) => React.ReactNode);
}
-export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) {
- const { documentInfo, savedObjectsClient, tagOptions } = props;
- const initialCopyOnSave = !Boolean(documentInfo.id);
+export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) {
+ const { documentInfo, tagOptions, objectType, onClose } = props;
+ const { id: documentId } = documentInfo;
+ const initialCopyOnSave = !Boolean(documentId);
+
+ const { capabilities } = pluginServices.getHooks();
+ const {
+ canAccessDashboards,
+ canCreateNewDashboards,
+ canEditDashboards,
+ } = capabilities.useService();
+
+ const disableDashboardOptions =
+ !canAccessDashboards() || (!canCreateNewDashboards && !canEditDashboards);
const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>(
- documentInfo.id ? null : 'existing'
+ documentId || disableDashboardOptions ? null : 'existing'
);
const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>(
null
);
const [copyOnSave, setCopyOnSave] = useState(initialCopyOnSave);
- const renderDashboardSelect = (state: SaveModalState) => {
- const isDisabled = Boolean(!state.copyOnSave && documentInfo.id);
-
- return (
- <>
-
-
-
-
-
-
- }
- />
-
-
- }
- hasChildLabel={false}
- >
-
-
-
setDashboardOption('existing')}
- disabled={isDisabled}
- />
-
-
- {
- setSelectedDashboard(dash);
- }}
- />
-
-
-
-
- setDashboardOption('new')}
- disabled={isDisabled}
- />
-
-
-
- setDashboardOption(null)}
- disabled={isDisabled}
- />
-
-
-
- >
- );
- };
+ const rightOptions = !disableDashboardOptions
+ ? () => (
+ {
+ setSelectedDashboard(dash);
+ }}
+ onChange={(option) => {
+ setDashboardOption(option);
+ }}
+ {...{ copyOnSave, documentId, dashboardOption }}
+ />
+ )
+ : null;
const onCopyOnSaveChange = (newCopyOnSave: boolean) => {
setDashboardOption(null);
@@ -159,7 +81,7 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) {
// Don't save with a dashboard ID if we're
// just updating an existing visualization
- if (!(!onSaveProps.newCopyOnSave && documentInfo.id)) {
+ if (!(!onSaveProps.newCopyOnSave && documentId)) {
if (dashboardOption === 'existing') {
dashboardId = selectedDashboard?.id || null;
} else {
@@ -171,13 +93,14 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) {
};
const saveLibraryLabel =
- !copyOnSave && documentInfo.id
+ !copyOnSave && documentId
? i18n.translate('presentationUtil.saveModalDashboard.saveLabel', {
defaultMessage: 'Save',
})
: i18n.translate('presentationUtil.saveModalDashboard.saveToLibraryLabel', {
defaultMessage: 'Save and add to library',
});
+
const saveDashboardLabel = i18n.translate(
'presentationUtil.saveModalDashboard.saveAndGoToDashboardLabel',
{
@@ -192,18 +115,20 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) {
return (
);
}
diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx
new file mode 100644
index 00000000000000..2044ecdd713e18
--- /dev/null
+++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React, { useState } from 'react';
+import { action } from '@storybook/addon-actions';
+
+import { StorybookParams } from '../services/storybook';
+import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector';
+
+export default {
+ component: SaveModalDashboardSelector,
+ title: 'Save Modal Dashboard Selector',
+ description: 'A selector for determining where an object will be saved after it is created.',
+ argTypes: {
+ hasDocumentId: {
+ control: 'boolean',
+ defaultValue: false,
+ },
+ copyOnSave: {
+ control: 'boolean',
+ defaultValue: false,
+ },
+ canCreateNewDashboards: {
+ control: 'boolean',
+ defaultValue: true,
+ },
+ canEditDashboards: {
+ control: 'boolean',
+ defaultValue: true,
+ },
+ },
+};
+
+export function Example({
+ copyOnSave,
+ hasDocumentId,
+}: {
+ copyOnSave: boolean;
+ hasDocumentId: boolean;
+} & StorybookParams) {
+ const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>('existing');
+
+ return (
+
+ );
+}
diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx
new file mode 100644
index 00000000000000..b1bf9ed6958420
--- /dev/null
+++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React from 'react';
+
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiRadio,
+ EuiIconTip,
+ EuiPanel,
+ EuiSpacer,
+} from '@elastic/eui';
+
+import { pluginServices } from '../services';
+import { DashboardPicker, DashboardPickerProps } from './dashboard_picker';
+
+import './saved_object_save_modal_dashboard.scss';
+
+export interface SaveModalDashboardSelectorProps {
+ copyOnSave: boolean;
+ documentId?: string;
+ onSelectDashboard: DashboardPickerProps['onChange'];
+
+ dashboardOption: 'new' | 'existing' | null;
+ onChange: (dashboardOption: 'new' | 'existing' | null) => void;
+}
+
+export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProps) {
+ const { documentId, onSelectDashboard, dashboardOption, onChange, copyOnSave } = props;
+ const { capabilities } = pluginServices.getHooks();
+ const { canCreateNewDashboards, canEditDashboards } = capabilities.useService();
+
+ const isDisabled = !copyOnSave && !!documentId;
+
+ return (
+ <>
+
+
+
+
+
+
+ }
+ />
+
+
+ }
+ hasChildLabel={false}
+ >
+
+
+ {canEditDashboards() && (
+ <>
+ {' '}
+
onChange('existing')}
+ disabled={isDisabled}
+ />
+
+
+
+
+ >
+ )}
+ {canCreateNewDashboards() && (
+ <>
+ {' '}
+ onChange('new')}
+ disabled={isDisabled}
+ />
+
+ >
+ )}
+ onChange(null)}
+ disabled={isDisabled}
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts
index baf40a1ea0ae4e..586ddd1320641c 100644
--- a/src/plugins/presentation_util/public/index.ts
+++ b/src/plugins/presentation_util/public/index.ts
@@ -10,9 +10,11 @@ import { PresentationUtilPlugin } from './plugin';
export {
SavedObjectSaveModalDashboard,
- DashboardSaveModalProps,
+ SaveModalDashboardProps,
} from './components/saved_object_save_modal_dashboard';
+export { DashboardPicker } from './components/dashboard_picker';
+
export function plugin() {
return new PresentationUtilPlugin();
}
diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts
index cbc1d0eb04e27a..5d3618b0346567 100644
--- a/src/plugins/presentation_util/public/plugin.ts
+++ b/src/plugins/presentation_util/public/plugin.ts
@@ -7,16 +7,39 @@
*/
import { CoreSetup, CoreStart, Plugin } from '../../../core/public';
-import { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types';
+import { pluginServices } from './services';
+import { registry } from './services/kibana';
+import {
+ PresentationUtilPluginSetup,
+ PresentationUtilPluginStart,
+ PresentationUtilPluginSetupDeps,
+ PresentationUtilPluginStartDeps,
+} from './types';
export class PresentationUtilPlugin
- implements Plugin {
- public setup(core: CoreSetup): PresentationUtilPluginSetup {
+ implements
+ Plugin<
+ PresentationUtilPluginSetup,
+ PresentationUtilPluginStart,
+ PresentationUtilPluginSetupDeps,
+ PresentationUtilPluginStartDeps
+ > {
+ public setup(
+ _coreSetup: CoreSetup,
+ _setupPlugins: PresentationUtilPluginSetupDeps
+ ): PresentationUtilPluginSetup {
return {};
}
- public start(core: CoreStart): PresentationUtilPluginStart {
- return {};
+ public async start(
+ coreStart: CoreStart,
+ startPlugins: PresentationUtilPluginStartDeps
+ ): Promise {
+ pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
+
+ return {
+ ContextProvider: pluginServices.getContextProvider(),
+ };
}
public stop() {}
diff --git a/src/plugins/presentation_util/public/services/create/factory.ts b/src/plugins/presentation_util/public/services/create/factory.ts
new file mode 100644
index 00000000000000..01b143e612461f
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/create/factory.ts
@@ -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
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { BehaviorSubject } from 'rxjs';
+import { CoreStart, AppUpdater } from 'src/core/public';
+
+/**
+ * A factory function for creating a service.
+ *
+ * The `Service` generic determines the shape of the API being produced.
+ * The `StartParameters` generic determines what parameters are expected to
+ * create the service.
+ */
+export type PluginServiceFactory = (params: Parameters) => Service;
+
+/**
+ * Parameters necessary to create a Kibana-based service, (e.g. during Plugin
+ * startup or setup).
+ *
+ * The `Start` generic refers to the specific Plugin `TPluginsStart`.
+ */
+export interface KibanaPluginServiceParams {
+ coreStart: CoreStart;
+ startPlugins: Start;
+ appUpdater?: BehaviorSubject;
+}
+
+/**
+ * A factory function for creating a Kibana-based service.
+ *
+ * The `Service` generic determines the shape of the API being produced.
+ * The `Setup` generic refers to the specific Plugin `TPluginsSetup`.
+ * The `Start` generic refers to the specific Plugin `TPluginsStart`.
+ */
+export type KibanaPluginServiceFactory = (
+ params: KibanaPluginServiceParams
+) => Service;
diff --git a/src/plugins/presentation_util/public/services/create/index.ts b/src/plugins/presentation_util/public/services/create/index.ts
new file mode 100644
index 00000000000000..59f1f9fd7a43b4
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/create/index.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { mapValues } from 'lodash';
+
+import { PluginServiceRegistry } from './registry';
+
+export { PluginServiceRegistry } from './registry';
+export { PluginServiceProvider, PluginServiceProviders } from './provider';
+export {
+ PluginServiceFactory,
+ KibanaPluginServiceFactory,
+ KibanaPluginServiceParams,
+} from './factory';
+
+/**
+ * `PluginServices` is a top-level class for specifying and accessing services within a plugin.
+ *
+ * A `PluginServices` object can be provided with a `PluginServiceRegistry` at any time, which will
+ * then be used to provide services to any component that accesses it.
+ *
+ * The `Services` generic determines the shape of all service APIs being produced.
+ */
+export class PluginServices {
+ private registry: PluginServiceRegistry | null = null;
+
+ /**
+ * Supply a `PluginServiceRegistry` for the class to use to provide services and context.
+ *
+ * @param registry A setup and started `PluginServiceRegistry`.
+ */
+ setRegistry(registry: PluginServiceRegistry | null) {
+ if (registry && !registry.isStarted()) {
+ throw new Error('Registry has not been started.');
+ }
+
+ this.registry = registry;
+ }
+
+ /**
+ * Returns true if a registry has been provided, false otherwise.
+ */
+ hasRegistry() {
+ return !!this.registry;
+ }
+
+ /**
+ * Private getter that will enforce proper setup throughout the class.
+ */
+ private getRegistry() {
+ if (!this.registry) {
+ throw new Error('No registry has been provided.');
+ }
+
+ return this.registry;
+ }
+
+ /**
+ * Return the React Context Provider that will supply services.
+ */
+ getContextProvider() {
+ return this.getRegistry().getContextProvider();
+ }
+
+ /**
+ * Return a map of React Hooks that can be used in React components.
+ */
+ getHooks(): { [K in keyof Services]: { useService: () => Services[K] } } {
+ const registry = this.getRegistry();
+ const providers = registry.getServiceProviders();
+
+ // @ts-expect-error Need to fix this; the type isn't fully understood when inferred.
+ return mapValues(providers, (provider) => ({
+ useService: provider.getUseServiceHook(),
+ }));
+ }
+}
diff --git a/src/plugins/presentation_util/public/services/create/provider.tsx b/src/plugins/presentation_util/public/services/create/provider.tsx
new file mode 100644
index 00000000000000..981ff1527f9819
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/create/provider.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React, { createContext, useContext } from 'react';
+import { PluginServiceFactory } from './factory';
+
+/**
+ * A collection of `PluginServiceProvider` objects, keyed by the `Services` API generic.
+ *
+ * The `Services` generic determines the shape of all service APIs being produced.
+ * The `StartParameters` generic determines what parameters are expected to
+ * start the service.
+ */
+export type PluginServiceProviders = {
+ [K in keyof Services]: PluginServiceProvider;
+};
+
+/**
+ * An object which uses a given factory to start, stop or provide a service.
+ *
+ * The `Service` generic determines the shape of the API being produced.
+ * The `StartParameters` generic determines what parameters are expected to
+ * start the service.
+ */
+export class PluginServiceProvider {
+ private factory: PluginServiceFactory;
+ private context = createContext(null);
+ private pluginService: Service | null = null;
+ public readonly Provider: React.FC = ({ children }) => {
+ return {children};
+ };
+
+ constructor(factory: PluginServiceFactory) {
+ this.factory = factory;
+ this.context.displayName = 'PluginServiceContext';
+ }
+
+ /**
+ * Private getter that will enforce proper setup throughout the class.
+ */
+ private getService() {
+ if (!this.pluginService) {
+ throw new Error('Service not started');
+ }
+ return this.pluginService;
+ }
+
+ /**
+ * Start the service.
+ *
+ * @param params Parameters used to start the service.
+ */
+ start(params: StartParameters) {
+ this.pluginService = this.factory(params);
+ }
+
+ /**
+ * Returns a function for providing a Context hook for the service.
+ */
+ getUseServiceHook() {
+ return () => {
+ const service = useContext(this.context);
+
+ if (!service) {
+ throw new Error('Provider is not set up correctly');
+ }
+
+ return service;
+ };
+ }
+
+ /**
+ * Stop the service.
+ */
+ stop() {
+ this.pluginService = null;
+ }
+}
diff --git a/src/plugins/presentation_util/public/services/create/registry.tsx b/src/plugins/presentation_util/public/services/create/registry.tsx
new file mode 100644
index 00000000000000..5165380780fa90
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/create/registry.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React from 'react';
+import { values } from 'lodash';
+import { PluginServiceProvider, PluginServiceProviders } from './provider';
+
+/**
+ * A `PluginServiceRegistry` maintains a set of service providers which can be collectively
+ * started, stopped or retreived.
+ *
+ * The `Services` generic determines the shape of all service APIs being produced.
+ * The `StartParameters` generic determines what parameters are expected to
+ * start the service.
+ */
+export class PluginServiceRegistry {
+ private providers: PluginServiceProviders;
+ private _isStarted = false;
+
+ constructor(providers: PluginServiceProviders) {
+ this.providers = providers;
+ }
+
+ /**
+ * Returns true if the registry has been started, false otherwise.
+ */
+ isStarted() {
+ return this._isStarted;
+ }
+
+ /**
+ * Returns a map of `PluginServiceProvider` objects.
+ */
+ getServiceProviders() {
+ if (!this._isStarted) {
+ throw new Error('Registry not started');
+ }
+ return this.providers;
+ }
+
+ /**
+ * Returns a React Context Provider for use in consuming applications.
+ */
+ getContextProvider() {
+ // Collect and combine Context.Provider elements from each Service Provider into a single
+ // Functional Component.
+ const provider: React.FC = ({ children }) => (
+ <>
+ {values>(this.getServiceProviders()).reduceRight(
+ (acc, serviceProvider) => {
+ return {acc};
+ },
+ children
+ )}
+ >
+ );
+
+ return provider;
+ }
+
+ /**
+ * Start the registry.
+ *
+ * @param params Parameters used to start the registry.
+ */
+ start(params: StartParameters) {
+ values>(this.providers).map((serviceProvider) =>
+ serviceProvider.start(params)
+ );
+ this._isStarted = true;
+ return this;
+ }
+
+ /**
+ * Stop the registry.
+ */
+ stop() {
+ values>(this.providers).map((serviceProvider) =>
+ serviceProvider.stop()
+ );
+ this._isStarted = false;
+ return this;
+ }
+}
diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts
new file mode 100644
index 00000000000000..732cc19e147631
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/index.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { SimpleSavedObject } from 'src/core/public';
+import { DashboardSavedObject } from 'src/plugins/dashboard/public';
+import { PluginServices } from './create';
+export interface PresentationDashboardsService {
+ findDashboards: (
+ query: string,
+ fields: string[]
+ ) => Promise>>;
+ findDashboardsByTitle: (title: string) => Promise>>;
+}
+
+export interface PresentationCapabilitiesService {
+ canAccessDashboards: () => boolean;
+ canCreateNewDashboards: () => boolean;
+ canEditDashboards: () => boolean;
+}
+
+export interface PresentationUtilServices {
+ dashboards: PresentationDashboardsService;
+ capabilities: PresentationCapabilitiesService;
+}
+
+export const pluginServices = new PluginServices();
diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts
new file mode 100644
index 00000000000000..f36b2779793581
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { PresentationUtilPluginStartDeps } from '../../types';
+import { KibanaPluginServiceFactory } from '../create';
+import { PresentationCapabilitiesService } from '..';
+
+export type CapabilitiesServiceFactory = KibanaPluginServiceFactory<
+ PresentationCapabilitiesService,
+ PresentationUtilPluginStartDeps
+>;
+
+export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => {
+ const { dashboard } = coreStart.application.capabilities;
+
+ return {
+ canAccessDashboards: () => Boolean(dashboard.show),
+ canCreateNewDashboards: () => Boolean(dashboard.createNew),
+ canEditDashboards: () => !Boolean(dashboard.hideWriteControls),
+ };
+};
diff --git a/src/plugins/presentation_util/public/services/kibana/dashboards.ts b/src/plugins/presentation_util/public/services/kibana/dashboards.ts
new file mode 100644
index 00000000000000..acfe4bd33e26ac
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/kibana/dashboards.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { DashboardSavedObject } from 'src/plugins/dashboard/public';
+
+import { PresentationUtilPluginStartDeps } from '../../types';
+import { KibanaPluginServiceFactory } from '../create';
+import { PresentationDashboardsService } from '..';
+
+export type DashboardsServiceFactory = KibanaPluginServiceFactory<
+ PresentationDashboardsService,
+ PresentationUtilPluginStartDeps
+>;
+
+export const dashboardsServiceFactory: DashboardsServiceFactory = ({ coreStart }) => {
+ const findDashboards = async (query: string = '', fields: string[] = []) => {
+ const { find } = coreStart.savedObjects.client;
+
+ const { savedObjects } = await find({
+ type: 'dashboard',
+ search: `${query}*`,
+ searchFields: fields,
+ });
+
+ return savedObjects;
+ };
+
+ const findDashboardsByTitle = async (title: string = '') => findDashboards(title, ['title']);
+
+ return {
+ findDashboards,
+ findDashboardsByTitle,
+ };
+};
diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts
new file mode 100644
index 00000000000000..a129b0d94479f1
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/kibana/index.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { dashboardsServiceFactory } from './dashboards';
+import { capabilitiesServiceFactory } from './capabilities';
+import {
+ PluginServiceProviders,
+ KibanaPluginServiceParams,
+ PluginServiceProvider,
+ PluginServiceRegistry,
+} from '../create';
+import { PresentationUtilPluginStartDeps } from '../../types';
+import { PresentationUtilServices } from '..';
+
+export { dashboardsServiceFactory } from './dashboards';
+export { capabilitiesServiceFactory } from './capabilities';
+
+export const providers: PluginServiceProviders<
+ PresentationUtilServices,
+ KibanaPluginServiceParams
+> = {
+ dashboards: new PluginServiceProvider(dashboardsServiceFactory),
+ capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
+};
+
+export const registry = new PluginServiceRegistry<
+ PresentationUtilServices,
+ KibanaPluginServiceParams
+>(providers);
diff --git a/src/plugins/presentation_util/public/services/storybook/capabilities.ts b/src/plugins/presentation_util/public/services/storybook/capabilities.ts
new file mode 100644
index 00000000000000..5048fe50cc0257
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/storybook/capabilities.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { PluginServiceFactory } from '../create';
+import { StorybookParams } from '.';
+import { PresentationCapabilitiesService } from '..';
+
+type CapabilitiesServiceFactory = PluginServiceFactory<
+ PresentationCapabilitiesService,
+ StorybookParams
+>;
+
+export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({
+ canAccessDashboards,
+ canCreateNewDashboards,
+ canEditDashboards,
+}) => {
+ const check = (value: boolean = true) => value;
+ return {
+ canAccessDashboards: () => check(canAccessDashboards),
+ canCreateNewDashboards: () => check(canCreateNewDashboards),
+ canEditDashboards: () => check(canEditDashboards),
+ };
+};
diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts
new file mode 100644
index 00000000000000..536cad3a9d1317
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/storybook/index.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
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { PluginServices, PluginServiceProviders, PluginServiceProvider } from '../create';
+import { dashboardsServiceFactory } from '../stub/dashboards';
+import { capabilitiesServiceFactory } from './capabilities';
+import { PresentationUtilServices } from '..';
+
+export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create';
+export { PresentationUtilServices } from '..';
+
+export interface StorybookParams {
+ canAccessDashboards?: boolean;
+ canCreateNewDashboards?: boolean;
+ canEditDashboards?: boolean;
+}
+
+export const providers: PluginServiceProviders = {
+ dashboards: new PluginServiceProvider(dashboardsServiceFactory),
+ capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
+};
+
+export const pluginServices = new PluginServices();
diff --git a/src/plugins/presentation_util/public/services/stub/capabilities.ts b/src/plugins/presentation_util/public/services/stub/capabilities.ts
new file mode 100644
index 00000000000000..33c091022421c0
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/stub/capabilities.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
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { PluginServiceFactory } from '../create';
+import { PresentationCapabilitiesService } from '..';
+
+type CapabilitiesServiceFactory = PluginServiceFactory;
+
+export const capabilitiesServiceFactory: CapabilitiesServiceFactory = () => ({
+ canAccessDashboards: () => true,
+ canCreateNewDashboards: () => true,
+ canEditDashboards: () => true,
+});
diff --git a/src/plugins/presentation_util/public/services/stub/dashboards.ts b/src/plugins/presentation_util/public/services/stub/dashboards.ts
new file mode 100644
index 00000000000000..862fa4f952c1e1
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/stub/dashboards.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { PluginServiceFactory } from '../create';
+import { PresentationDashboardsService } from '..';
+
+// TODO (clint): Create set of dashboards to stub and return.
+
+type DashboardsServiceFactory = PluginServiceFactory;
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export const dashboardsServiceFactory: DashboardsServiceFactory = () => ({
+ findDashboards: async (query: string = '', _fields: string[] = []) => {
+ if (!query) {
+ return [];
+ }
+
+ await sleep(2000);
+ return [];
+ },
+ findDashboardsByTitle: async (title: string) => {
+ if (!title) {
+ return [];
+ }
+
+ await sleep(2000);
+ return [];
+ },
+});
diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts
new file mode 100644
index 00000000000000..a2bde357fd4c0f
--- /dev/null
+++ b/src/plugins/presentation_util/public/services/stub/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { dashboardsServiceFactory } from './dashboards';
+import { capabilitiesServiceFactory } from './capabilities';
+import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create';
+import { PresentationUtilServices } from '..';
+
+export { dashboardsServiceFactory } from './dashboards';
+export { capabilitiesServiceFactory } from './capabilities';
+
+export const providers: PluginServiceProviders = {
+ dashboards: new PluginServiceProvider(dashboardsServiceFactory),
+ capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
+};
+
+export const registry = new PluginServiceRegistry(providers);
diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts
index ae5646bd9bbae4..7371ebc6f736e5 100644
--- a/src/plugins/presentation_util/public/types.ts
+++ b/src/plugins/presentation_util/public/types.ts
@@ -8,5 +8,12 @@
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PresentationUtilPluginSetup {}
+
+export interface PresentationUtilPluginStart {
+ ContextProvider: React.FC;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface PresentationUtilPluginSetupDeps {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
-export interface PresentationUtilPluginStart {}
+export interface PresentationUtilPluginStartDeps {}
diff --git a/src/plugins/presentation_util/storybook/decorator.tsx b/src/plugins/presentation_util/storybook/decorator.tsx
new file mode 100644
index 00000000000000..5f56c70a2f849f
--- /dev/null
+++ b/src/plugins/presentation_util/storybook/decorator.tsx
@@ -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
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React from 'react';
+
+import { DecoratorFn } from '@storybook/react';
+import { I18nProvider } from '@kbn/i18n/react';
+import { pluginServices } from '../public/services';
+import { PresentationUtilServices } from '../public/services';
+import { providers, StorybookParams } from '../public/services/storybook';
+import { PluginServiceRegistry } from '../public/services/create';
+
+export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => {
+ const registry = new PluginServiceRegistry(providers);
+ pluginServices.setRegistry(registry.start(storybook.args));
+ const ContextProvider = pluginServices.getContextProvider();
+
+ return (
+
+ {story()}
+
+ );
+};
diff --git a/src/plugins/presentation_util/storybook/main.ts b/src/plugins/presentation_util/storybook/main.ts
new file mode 100644
index 00000000000000..d12b98f38a03f5
--- /dev/null
+++ b/src/plugins/presentation_util/storybook/main.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { Configuration } from 'webpack';
+import { defaultConfig } from '@kbn/storybook';
+import webpackConfig from '@kbn/storybook/target/webpack.config';
+
+module.exports = {
+ ...defaultConfig,
+ addons: ['@storybook/addon-essentials'],
+ webpackFinal: (config: Configuration) => {
+ return webpackConfig({ config });
+ },
+};
diff --git a/src/plugins/presentation_util/storybook/manager.ts b/src/plugins/presentation_util/storybook/manager.ts
new file mode 100644
index 00000000000000..e9b6a11242036c
--- /dev/null
+++ b/src/plugins/presentation_util/storybook/manager.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import { addons } from '@storybook/addons';
+import { create } from '@storybook/theming';
+import { PANEL_ID } from '@storybook/addon-actions';
+
+addons.setConfig({
+ theme: create({
+ base: 'light',
+ brandTitle: 'Kibana Presentation Utility Storybook',
+ brandUrl: 'https://github.com/elastic/kibana/tree/master/src/plugins/presentation_util',
+ }),
+ showPanel: true.valueOf,
+ selectedPanel: PANEL_ID,
+});
diff --git a/src/plugins/presentation_util/storybook/preview.tsx b/src/plugins/presentation_util/storybook/preview.tsx
new file mode 100644
index 00000000000000..dfa8ad3be04e7c
--- /dev/null
+++ b/src/plugins/presentation_util/storybook/preview.tsx
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * and the Server Side Public License, v 1; you may not use this file except in
+ * compliance with, at your election, the Elastic License or the Server Side
+ * Public License, v 1.
+ */
+
+import React from 'react';
+import { addDecorator } from '@storybook/react';
+import { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks';
+
+import { servicesContextDecorator } from './decorator';
+
+addDecorator(servicesContextDecorator);
+
+export const parameters = {
+ docs: {
+ page: () => (
+ <>
+
+
+
+
+
+ >
+ ),
+ },
+};
diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json
index 1e3756f45e9537..a9657db2888486 100644
--- a/src/plugins/presentation_util/tsconfig.json
+++ b/src/plugins/presentation_util/tsconfig.json
@@ -7,7 +7,7 @@
"declaration": true,
"declarationMap": true
},
- "include": ["common/**/*", "public/**/*"],
+ "include": ["common/**/*", "public/**/*", "storybook/**/*", "../../../typings/**/*"],
"references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../dashboard/tsconfig.json" },
diff --git a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx
index 6702255ee2e2c2..f87169d4b828ad 100644
--- a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx
+++ b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx
@@ -31,7 +31,8 @@ interface MinimalSaveModalProps {
export function showSaveModal(
saveModal: React.ReactElement,
- I18nContext: I18nStart['Context']
+ I18nContext: I18nStart['Context'],
+ Wrapper?: React.FC
) {
const container = document.createElement('div');
const closeModal = () => {
@@ -55,5 +56,13 @@ export function showSaveModal(
onClose: closeModal,
});
- ReactDOM.render({element}, container);
+ const wrappedElement = Wrapper ? (
+
+ {element}
+
+ ) : (
+ {element}
+ );
+
+ ReactDOM.render(wrappedElement, container);
}
diff --git a/src/plugins/saved_objects_management/common/index.ts b/src/plugins/saved_objects_management/common/index.ts
index a8395e602979c9..8850899e38958a 100644
--- a/src/plugins/saved_objects_management/common/index.ts
+++ b/src/plugins/saved_objects_management/common/index.ts
@@ -6,4 +6,11 @@
* Public License, v 1.
*/
-export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types';
+export {
+ SavedObjectWithMetadata,
+ SavedObjectMetadata,
+ SavedObjectRelation,
+ SavedObjectRelationKind,
+ SavedObjectInvalidRelation,
+ SavedObjectGetRelationshipsResponse,
+} from './types';
diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts
index 8618cf4332acff..e100dfc6b23e6b 100644
--- a/src/plugins/saved_objects_management/common/types.ts
+++ b/src/plugins/saved_objects_management/common/types.ts
@@ -28,12 +28,26 @@ export type SavedObjectWithMetadata = SavedObject & {
meta: SavedObjectMetadata;
};
+export type SavedObjectRelationKind = 'child' | 'parent';
+
/**
* Represents a relation between two {@link SavedObject | saved object}
*/
export interface SavedObjectRelation {
id: string;
type: string;
- relationship: 'child' | 'parent';
+ relationship: SavedObjectRelationKind;
meta: SavedObjectMetadata;
}
+
+export interface SavedObjectInvalidRelation {
+ id: string;
+ type: string;
+ relationship: SavedObjectRelationKind;
+ error: string;
+}
+
+export interface SavedObjectGetRelationshipsResponse {
+ relations: SavedObjectRelation[];
+ invalidRelations: SavedObjectInvalidRelation[];
+}
diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts
index b609fac67dac12..4454907f530fe5 100644
--- a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts
+++ b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts
@@ -6,6 +6,7 @@
* Public License, v 1.
*/
+import { SavedObjectGetRelationshipsResponse } from '../types';
import { httpServiceMock } from '../../../../core/public/mocks';
import { getRelationships } from './get_relationships';
@@ -22,13 +23,17 @@ describe('getRelationships', () => {
});
it('should handle successful responses', async () => {
- httpMock.get.mockResolvedValue([1, 2]);
+ const serverResponse: SavedObjectGetRelationshipsResponse = {
+ relations: [],
+ invalidRelations: [],
+ };
+ httpMock.get.mockResolvedValue(serverResponse);
const response = await getRelationships(httpMock, 'dashboard', '1', [
'search',
'index-pattern',
]);
- expect(response).toEqual([1, 2]);
+ expect(response).toEqual(serverResponse);
});
it('should handle errors', async () => {
diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.ts
index 0eb97e1052fa44..69aeb6fbf580b8 100644
--- a/src/plugins/saved_objects_management/public/lib/get_relationships.ts
+++ b/src/plugins/saved_objects_management/public/lib/get_relationships.ts
@@ -8,19 +8,19 @@
import { HttpStart } from 'src/core/public';
import { get } from 'lodash';
-import { SavedObjectRelation } from '../types';
+import { SavedObjectGetRelationshipsResponse } from '../types';
export async function getRelationships(
http: HttpStart,
type: string,
id: string,
savedObjectTypes: string[]
-): Promise {
+): Promise {
const url = `/api/kibana/management/saved_objects/relationships/${encodeURIComponent(
type
)}/${encodeURIComponent(id)}`;
try {
- return await http.get(url, {
+ return await http.get(url, {
query: {
savedObjectTypes,
},
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap
index 15e5cb89b622c6..c39263f3042495 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap
@@ -28,133 +28,131 @@ exports[`Relationships should render dashboards normally 1`] = `
-
-
-
- Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children.
-
-
-
-
+
+ Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children.
+
+
+
+
-
+ }
+ tableLayout="fixed"
+ />
`;
@@ -231,138 +229,315 @@ exports[`Relationships should render index patterns normally 1`] = `
-
-
-
- Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children.
-
-
-
-
+
+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children.
+
+
+
+
+
+
+`;
+
+exports[`Relationships should render invalid relations 1`] = `
+
+
+
+
+
+
+
+
+ MyIndexPattern*
+
+
+
+
+
+
+
+
+
+
+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children.
+
+
+
+
-
+ }
+ tableLayout="fixed"
+ />
`;
@@ -395,138 +570,136 @@ exports[`Relationships should render searches normally 1`] = `
-
-
-
- Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children.
-
-
-
-
+
+ Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children.
+
+
+
+
-
+ }
+ tableLayout="fixed"
+ />
`;
@@ -559,133 +732,131 @@ exports[`Relationships should render visualizations normally 1`] = `
-
-
-
- Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children.
-
-
-
-
+
+ Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children.
+
+
+
+
-
+ }
+ tableLayout="fixed"
+ />
`;
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx
index 72a4b0f2788fa5..e590520193bba8 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx
@@ -25,36 +25,39 @@ describe('Relationships', () => {
goInspectObject: () => {},
canGoInApp: () => true,
basePath: httpServiceMock.createSetupContract().basePath,
- getRelationships: jest.fn().mockImplementation(() => [
- {
- type: 'search',
- id: '1',
- relationship: 'parent',
- meta: {
- editUrl: '/management/kibana/objects/savedSearches/1',
- icon: 'search',
- inAppUrl: {
- path: '/app/discover#//1',
- uiCapabilitiesPath: 'discover.show',
+ getRelationships: jest.fn().mockImplementation(() => ({
+ relations: [
+ {
+ type: 'search',
+ id: '1',
+ relationship: 'parent',
+ meta: {
+ editUrl: '/management/kibana/objects/savedSearches/1',
+ icon: 'search',
+ inAppUrl: {
+ path: '/app/discover#//1',
+ uiCapabilitiesPath: 'discover.show',
+ },
+ title: 'My Search Title',
},
- title: 'My Search Title',
},
- },
- {
- type: 'visualization',
- id: '2',
- relationship: 'parent',
- meta: {
- editUrl: '/management/kibana/objects/savedVisualizations/2',
- icon: 'visualizeApp',
- inAppUrl: {
- path: '/app/visualize#/edit/2',
- uiCapabilitiesPath: 'visualize.show',
+ {
+ type: 'visualization',
+ id: '2',
+ relationship: 'parent',
+ meta: {
+ editUrl: '/management/kibana/objects/savedVisualizations/2',
+ icon: 'visualizeApp',
+ inAppUrl: {
+ path: '/app/visualize#/edit/2',
+ uiCapabilitiesPath: 'visualize.show',
+ },
+ title: 'My Visualization Title',
},
- title: 'My Visualization Title',
},
- },
- ]),
+ ],
+ invalidRelations: [],
+ })),
savedObject: {
id: '1',
type: 'index-pattern',
@@ -92,36 +95,39 @@ describe('Relationships', () => {
goInspectObject: () => {},
canGoInApp: () => true,
basePath: httpServiceMock.createSetupContract().basePath,
- getRelationships: jest.fn().mockImplementation(() => [
- {
- type: 'index-pattern',
- id: '1',
- relationship: 'child',
- meta: {
- editUrl: '/management/kibana/indexPatterns/patterns/1',
- icon: 'indexPatternApp',
- inAppUrl: {
- path: '/app/management/kibana/indexPatterns/patterns/1',
- uiCapabilitiesPath: 'management.kibana.indexPatterns',
+ getRelationships: jest.fn().mockImplementation(() => ({
+ relations: [
+ {
+ type: 'index-pattern',
+ id: '1',
+ relationship: 'child',
+ meta: {
+ editUrl: '/management/kibana/indexPatterns/patterns/1',
+ icon: 'indexPatternApp',
+ inAppUrl: {
+ path: '/app/management/kibana/indexPatterns/patterns/1',
+ uiCapabilitiesPath: 'management.kibana.indexPatterns',
+ },
+ title: 'My Index Pattern',
},
- title: 'My Index Pattern',
},
- },
- {
- type: 'visualization',
- id: '2',
- relationship: 'parent',
- meta: {
- editUrl: '/management/kibana/objects/savedVisualizations/2',
- icon: 'visualizeApp',
- inAppUrl: {
- path: '/app/visualize#/edit/2',
- uiCapabilitiesPath: 'visualize.show',
+ {
+ type: 'visualization',
+ id: '2',
+ relationship: 'parent',
+ meta: {
+ editUrl: '/management/kibana/objects/savedVisualizations/2',
+ icon: 'visualizeApp',
+ inAppUrl: {
+ path: '/app/visualize#/edit/2',
+ uiCapabilitiesPath: 'visualize.show',
+ },
+ title: 'My Visualization Title',
},
- title: 'My Visualization Title',
},
- },
- ]),
+ ],
+ invalidRelations: [],
+ })),
savedObject: {
id: '1',
type: 'search',
@@ -159,36 +165,39 @@ describe('Relationships', () => {
goInspectObject: () => {},
canGoInApp: () => true,
basePath: httpServiceMock.createSetupContract().basePath,
- getRelationships: jest.fn().mockImplementation(() => [
- {
- type: 'dashboard',
- id: '1',
- relationship: 'parent',
- meta: {
- editUrl: '/management/kibana/objects/savedDashboards/1',
- icon: 'dashboardApp',
- inAppUrl: {
- path: '/app/kibana#/dashboard/1',
- uiCapabilitiesPath: 'dashboard.show',
+ getRelationships: jest.fn().mockImplementation(() => ({
+ relations: [
+ {
+ type: 'dashboard',
+ id: '1',
+ relationship: 'parent',
+ meta: {
+ editUrl: '/management/kibana/objects/savedDashboards/1',
+ icon: 'dashboardApp',
+ inAppUrl: {
+ path: '/app/kibana#/dashboard/1',
+ uiCapabilitiesPath: 'dashboard.show',
+ },
+ title: 'My Dashboard 1',
},
- title: 'My Dashboard 1',
},
- },
- {
- type: 'dashboard',
- id: '2',
- relationship: 'parent',
- meta: {
- editUrl: '/management/kibana/objects/savedDashboards/2',
- icon: 'dashboardApp',
- inAppUrl: {
- path: '/app/kibana#/dashboard/2',
- uiCapabilitiesPath: 'dashboard.show',
+ {
+ type: 'dashboard',
+ id: '2',
+ relationship: 'parent',
+ meta: {
+ editUrl: '/management/kibana/objects/savedDashboards/2',
+ icon: 'dashboardApp',
+ inAppUrl: {
+ path: '/app/kibana#/dashboard/2',
+ uiCapabilitiesPath: 'dashboard.show',
+ },
+ title: 'My Dashboard 2',
},
- title: 'My Dashboard 2',
},
- },
- ]),
+ ],
+ invalidRelations: [],
+ })),
savedObject: {
id: '1',
type: 'visualization',
@@ -226,36 +235,39 @@ describe('Relationships', () => {
goInspectObject: () => {},
canGoInApp: () => true,
basePath: httpServiceMock.createSetupContract().basePath,
- getRelationships: jest.fn().mockImplementation(() => [
- {
- type: 'visualization',
- id: '1',
- relationship: 'child',
- meta: {
- editUrl: '/management/kibana/objects/savedVisualizations/1',
- icon: 'visualizeApp',
- inAppUrl: {
- path: '/app/visualize#/edit/1',
- uiCapabilitiesPath: 'visualize.show',
+ getRelationships: jest.fn().mockImplementation(() => ({
+ relations: [
+ {
+ type: 'visualization',
+ id: '1',
+ relationship: 'child',
+ meta: {
+ editUrl: '/management/kibana/objects/savedVisualizations/1',
+ icon: 'visualizeApp',
+ inAppUrl: {
+ path: '/app/visualize#/edit/1',
+ uiCapabilitiesPath: 'visualize.show',
+ },
+ title: 'My Visualization Title 1',
},
- title: 'My Visualization Title 1',
},
- },
- {
- type: 'visualization',
- id: '2',
- relationship: 'child',
- meta: {
- editUrl: '/management/kibana/objects/savedVisualizations/2',
- icon: 'visualizeApp',
- inAppUrl: {
- path: '/app/visualize#/edit/2',
- uiCapabilitiesPath: 'visualize.show',
+ {
+ type: 'visualization',
+ id: '2',
+ relationship: 'child',
+ meta: {
+ editUrl: '/management/kibana/objects/savedVisualizations/2',
+ icon: 'visualizeApp',
+ inAppUrl: {
+ path: '/app/visualize#/edit/2',
+ uiCapabilitiesPath: 'visualize.show',
+ },
+ title: 'My Visualization Title 2',
},
- title: 'My Visualization Title 2',
},
- },
- ]),
+ ],
+ invalidRelations: [],
+ })),
savedObject: {
id: '1',
type: 'dashboard',
@@ -324,4 +336,49 @@ describe('Relationships', () => {
expect(props.getRelationships).toHaveBeenCalled();
expect(component).toMatchSnapshot();
});
+
+ it('should render invalid relations', async () => {
+ const props: RelationshipsProps = {
+ goInspectObject: () => {},
+ canGoInApp: () => true,
+ basePath: httpServiceMock.createSetupContract().basePath,
+ getRelationships: jest.fn().mockImplementation(() => ({
+ relations: [],
+ invalidRelations: [
+ {
+ id: '1',
+ type: 'dashboard',
+ relationship: 'child',
+ error: 'Saved object [dashboard/1] not found',
+ },
+ ],
+ })),
+ savedObject: {
+ id: '1',
+ type: 'index-pattern',
+ attributes: {},
+ references: [],
+ meta: {
+ title: 'MyIndexPattern*',
+ icon: 'indexPatternApp',
+ editUrl: '#/management/kibana/indexPatterns/patterns/1',
+ inAppUrl: {
+ path: '/management/kibana/indexPatterns/patterns/1',
+ uiCapabilitiesPath: 'management.kibana.indexPatterns',
+ },
+ },
+ },
+ close: jest.fn(),
+ };
+
+ const component = shallowWithI18nProvider();
+
+ // Ensure all promises resolve
+ await new Promise((resolve) => process.nextTick(resolve));
+ // Ensure the state changes are reflected
+ component.update();
+
+ expect(props.getRelationships).toHaveBeenCalled();
+ expect(component).toMatchSnapshot();
+ });
});
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx
index 2d62699b6f1f23..aee61f7bc9c7a7 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx
@@ -26,11 +26,17 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { IBasePath } from 'src/core/public';
import { getDefaultTitle, getSavedObjectLabel } from '../../../lib';
-import { SavedObjectWithMetadata, SavedObjectRelation } from '../../../types';
+import {
+ SavedObjectWithMetadata,
+ SavedObjectRelationKind,
+ SavedObjectRelation,
+ SavedObjectInvalidRelation,
+ SavedObjectGetRelationshipsResponse,
+} from '../../../types';
export interface RelationshipsProps {
basePath: IBasePath;
- getRelationships: (type: string, id: string) => Promise;
+ getRelationships: (type: string, id: string) => Promise;
savedObject: SavedObjectWithMetadata;
close: () => void;
goInspectObject: (obj: SavedObjectWithMetadata) => void;
@@ -38,17 +44,47 @@ export interface RelationshipsProps {
}
export interface RelationshipsState {
- relationships: SavedObjectRelation[];
+ relations: SavedObjectRelation[];
+ invalidRelations: SavedObjectInvalidRelation[];
isLoading: boolean;
error?: string;
}
+const relationshipColumn = {
+ field: 'relationship',
+ name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnRelationshipName', {
+ defaultMessage: 'Direct relationship',
+ }),
+ dataType: 'string',
+ sortable: false,
+ width: '125px',
+ 'data-test-subj': 'directRelationship',
+ render: (relationship: SavedObjectRelationKind) => {
+ return (
+
+ {relationship === 'parent' ? (
+
+ ) : (
+
+ )}
+
+ );
+ },
+};
+
export class Relationships extends Component {
constructor(props: RelationshipsProps) {
super(props);
this.state = {
- relationships: [],
+ relations: [],
+ invalidRelations: [],
isLoading: false,
error: undefined,
};
@@ -70,8 +106,11 @@ export class Relationships extends Component
+
+
+ ({
+ 'data-test-subj': `invalidRelationshipsTableRow`,
+ })}
+ />
+
+ >
+ );
+ }
+
+ renderRelationshipsTable() {
+ const { goInspectObject, basePath, savedObject } = this.props;
+ const { relations, isLoading, error } = this.state;
if (error) {
return this.renderError();
@@ -137,39 +250,7 @@ export class Relationships extends Component {
- if (relationship === 'parent') {
- return (
-
-
-
- );
- }
- if (relationship === 'child') {
- return (
-
-
-
- );
- }
- },
- },
+ relationshipColumn,
{
field: 'meta.title',
name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTitleName', {
@@ -224,7 +305,7 @@ export class Relationships extends Component [
+ relations.map((relationship) => [
relationship.type,
{
value: relationship.type,
@@ -277,7 +358,7 @@ export class Relationships extends Component
+ <>
{i18n.translate(
@@ -296,7 +377,7 @@ export class Relationships extends Component
-
+ >
);
}
@@ -328,8 +409,10 @@ export class Relationships extends Component