@@ -242,7 +293,7 @@ export function DashboardApp({
// The user can still request a reload in the query bar, even if the
// query is the same, and in that case, we have to explicitly ask for
// a reload, since no state changes will cause it.
- setLastReloadTime(() => new Date().getTime());
+ triggerRefresh$.next({ force: true });
}
}}
/>
diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts
index 90706a11b8ce2..c52bd1b4d47b8 100644
--- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts
+++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts
@@ -72,7 +72,7 @@ export class DashboardStateManager {
>;
private readonly stateContainerChangeSub: Subscription;
private readonly STATE_STORAGE_KEY = '_a';
- private readonly kbnUrlStateStorage: IKbnUrlStateStorage;
+ public readonly kbnUrlStateStorage: IKbnUrlStateStorage;
private readonly stateSyncRef: ISyncStateRef;
private readonly history: History;
private readonly usageCollection: UsageCollectionSetup | undefined;
@@ -596,7 +596,7 @@ export class DashboardStateManager {
this.toUrlState(this.stateContainer.get())
);
// immediately forces scheduled updates and changes location
- return this.kbnUrlStateStorage.flush({ replace });
+ return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace);
}
// TODO: find nicer solution for this
diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap
index bce8a661634f6..faec6b4f6f24b 100644
--- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap
+++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap
@@ -4,10 +4,16 @@ exports[`after fetch When given a title that matches multiple dashboards, filter
- getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging),
- [savedObjectsTagging, redirectTo]
+ getTableColumns(
+ core.application,
+ kbnUrlStateStorage,
+ core.uiSettings.get('state:storeInSessionStorage'),
+ savedObjectsTagging
+ ),
+ [core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging]
);
const noItemsFragment = useMemo(
@@ -99,7 +103,6 @@ export const DashboardListing = ({
(filter: string) => {
let searchTerm = filter;
let references: SavedObjectsFindOptionsReference[] | undefined;
-
if (savedObjectsTagging) {
const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, {
useName: true,
@@ -164,7 +167,9 @@ export const DashboardListing = ({
};
const getTableColumns = (
- redirectTo: (id?: string) => void,
+ application: ApplicationStart,
+ kbnUrlStateStorage: IKbnUrlStateStorage,
+ useHash: boolean,
savedObjectsTagging?: SavedObjectsTaggingApi
) => {
return [
@@ -172,9 +177,15 @@ const getTableColumns = (
field: 'title',
name: dashboardListingTable.getTitleColumnName(),
sortable: true,
- render: (field: string, record: { id: string; title: string }) => (
+ render: (field: string, record: { id: string; title: string; timeRestore: boolean }) => (
redirectTo(record.id)}
+ href={getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ useHash,
+ record.id,
+ record.timeRestore
+ )}
data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts
new file mode 100644
index 0000000000000..6dbc76803af90
--- /dev/null
+++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { getDashboardListItemLink } from './get_dashboard_list_item_link';
+import { ApplicationStart } from 'kibana/public';
+import { esFilters } from '../../../../data/public';
+import { createHashHistory } from 'history';
+import { createKbnUrlStateStorage } from '../../../../kibana_utils/public';
+import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator';
+
+const DASHBOARD_ID = '13823000-99b9-11ea-9eb6-d9e8adceb647';
+
+const application = ({
+ getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => {
+ return `/app/${appId}${options?.path}`;
+ }),
+} as unknown) as ApplicationStart;
+
+const history = createHashHistory();
+const kbnUrlStateStorage = createKbnUrlStateStorage({
+ history,
+ useHash: false,
+});
+kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } });
+
+describe('listing dashboard link', () => {
+ test('creates a link to a dashboard without the timerange query if time is saved on the dashboard', async () => {
+ const url = getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ false,
+ DASHBOARD_ID,
+ true
+ );
+ expect(url).toMatchInlineSnapshot(`"/app/dashboards#/view/${DASHBOARD_ID}?_g=()"`);
+ });
+
+ test('creates a link to a dashboard with the timerange query if time is not saved on the dashboard', async () => {
+ const url = getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ false,
+ DASHBOARD_ID,
+ false
+ );
+ expect(url).toMatchInlineSnapshot(
+ `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:now-7d,to:now))"`
+ );
+ });
+});
+
+describe('when global time changes', () => {
+ beforeEach(() => {
+ kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
+ time: {
+ from: '2021-01-05T11:45:53.375Z',
+ to: '2021-01-21T11:46:00.990Z',
+ },
+ });
+ });
+
+ test('propagates the correct time on the query', async () => {
+ const url = getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ false,
+ DASHBOARD_ID,
+ false
+ );
+ expect(url).toMatchInlineSnapshot(
+ `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"`
+ );
+ });
+});
+
+describe('when global refreshInterval changes', () => {
+ beforeEach(() => {
+ kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
+ refreshInterval: { pause: false, value: 300 },
+ });
+ });
+
+ test('propagates the refreshInterval on the query', async () => {
+ const url = getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ false,
+ DASHBOARD_ID,
+ false
+ );
+ expect(url).toMatchInlineSnapshot(
+ `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(refreshInterval:(pause:!f,value:300))"`
+ );
+ });
+});
+
+describe('when global filters change', () => {
+ beforeEach(() => {
+ const filters = [
+ {
+ meta: {
+ alias: null,
+ disabled: false,
+ negate: false,
+ },
+ query: { query: 'q1' },
+ },
+ {
+ meta: {
+ alias: null,
+ disabled: false,
+ negate: false,
+ },
+ query: { query: 'q1' },
+ $state: {
+ store: esFilters.FilterStateStore.GLOBAL_STATE,
+ },
+ },
+ ];
+ kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
+ filters,
+ });
+ });
+
+ test('propagates the filters on the query', async () => {
+ const url = getDashboardListItemLink(
+ application,
+ kbnUrlStateStorage,
+ false,
+ DASHBOARD_ID,
+ false
+ );
+ expect(url).toMatchInlineSnapshot(
+ `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"`
+ );
+ });
+});
diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts
new file mode 100644
index 0000000000000..d14638b9e231f
--- /dev/null
+++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { ApplicationStart } from 'kibana/public';
+import { QueryState } from '../../../../data/public';
+import { setStateToKbnUrl } from '../../../../kibana_utils/public';
+import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants';
+import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator';
+import { IKbnUrlStateStorage } from '../../services/kibana_utils';
+
+export const getDashboardListItemLink = (
+ application: ApplicationStart,
+ kbnUrlStateStorage: IKbnUrlStateStorage,
+ useHash: boolean,
+ id: string,
+ timeRestore: boolean
+) => {
+ let url = application.getUrlForApp(DashboardConstants.DASHBOARDS_ID, {
+ path: `#${createDashboardEditUrl(id)}`,
+ });
+ const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {};
+
+ if (timeRestore) {
+ delete globalStateInUrl.time;
+ delete globalStateInUrl.refreshInterval;
+ }
+ url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url);
+ return url;
+};
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 0000000000000..d4703d14627a4
--- /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 660e7635eb99d..fe4f8ea411289 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,
@@ -94,7 +103,7 @@ export function ShowShareModal({
share.toggleShareContextMenu({
anchorElement,
allowEmbed: true,
- allowShortUrl: !dashboardCapabilities.hideWriteControls || dashboardCapabilities.createShortUrl,
+ allowShortUrl: dashboardCapabilities.createShortUrl,
shareableUrl: setStateToKbnUrl(
'_a',
dashboardStateManager.getAppState(),
@@ -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 cc784f5f81c9e..4bd43d1cd64a9 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 0000000000000..3da6a8050f14c
--- /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 0000000000000..0ae14cdcf7197
--- /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 0000000000000..df78d68aaef48
--- /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 a72c7f2db41a8..6da9c3aa293ef 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 328f05fac8594..08fe2b07096bb 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 6d7654c6659f2..c2a4beb9b61a5 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 554e8385881f2..bb60f0d7b4ad4 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 0000000000000..ae48468abc209
--- /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 0000000000000..dc892d95ae397
--- /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 0000000000000..88134e1c6ea03
--- /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 0000000000000..725a847aa0e3f
--- /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 0000000000000..7f2a3b2e6e143
--- /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 0000000000000..ff6879f2b8960
--- /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 0000000000000..cd6e1cb2c5977
--- /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 9e493f46b0781..f533af2db9672 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -2282,8 +2282,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)
@@ -2360,6 +2363,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(): {
@@ -2451,7 +2456,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)
@@ -2601,7 +2606,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/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
index 19035eb8a9328..991f3063cac0a 100644
--- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
+++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts
@@ -90,7 +90,7 @@ describe('sync_query_state_with_url', () => {
test('url is actually changed when data in services changes', () => {
const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
filterManager.setFilters([gF, aF]);
- kbnUrlStateStorage.flush(); // sync force location change
+ kbnUrlStateStorage.kbnUrlControls.flush(); // sync force location change
expect(history.location.hash).toMatchInlineSnapshot(
`"#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"`
);
@@ -126,7 +126,7 @@ describe('sync_query_state_with_url', () => {
test('when url is changed, filters synced back to filterManager', () => {
const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage);
- kbnUrlStateStorage.cancel(); // stop initial syncing pending update
+ kbnUrlStateStorage.kbnUrlControls.cancel(); // stop initial syncing pending update
history.push(pathWithFilter);
expect(filterManager.getGlobalFilters()).toHaveLength(1);
stop();
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 adb422c1d18e7..6a4cb9c494b4f 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 fff06d2e1bfb6..d241eecfd8d5d 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 0000000000000..929f25e234a60
--- /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 8a4248e48185b..6d11f3a16b09e 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 ee2703b888bf1..6b9ce1b422481 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 d62cb311bf6a4..5806ef8676b9b 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 d140e713f9440..7d303543a0c57 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 5ae01eccdd920..bfd73951c31c4 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 f6ca9ef1a993d..6dfc8faea769e 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 426302689c8f0..b7d9be485a303 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,12 +84,15 @@ 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();
});
- it('Should render the given query', async () => {
+ it.skip('Should render the given query', async () => {
const { getByText } = render(
wrapQueryStringInputInContext({
query: kqlQuery,
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 8e66729825e39..eeef46381732e 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 a11bbe11f3f95..c176a50627b92 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 { 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 }, { esClient, uiSettingsClient }) => {
// Only default index pattern type is supported here.
// See data_enhanced for other type support.
@@ -30,15 +37,19 @@ 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);
- return toKibanaSearchResponse(body);
+ 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);
+ return toKibanaSearchResponse(body);
+ } 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 0000000000000..e30b7bdaa8402
--- /dev/null
+++ b/src/plugins/data/server/search/routes/bsearch.ts
@@ -0,0 +1,65 @@
+/*
+ * 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, map } 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';
+import { shimHitsTotal } from './shim_hits_total';
+
+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(),
+ map((response) => {
+ return {
+ ...response,
+ ...{
+ rawResponse: shimHitsTotal(response.rawResponse),
+ },
+ };
+ }),
+ 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 6578774f65a3c..fc30e2f29c3ef 100644
--- a/src/plugins/data/server/search/routes/call_msearch.ts
+++ b/src/plugins/data/server/search/routes/call_msearch.ts
@@ -8,12 +8,12 @@
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 { shimHitsTotal } from './shim_hits_total';
+import { getKbnServerError } from '../../../../kibana_utils/server';
import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..';
/** @internal */
@@ -48,6 +48,9 @@ interface CallMsearchDependencies {
* @internal
*/
export function getCallMsearch(dependencies: CallMsearchDependencies) {
+ /**
+ * @throws KbnServerError
+ */
return async (params: {
body: MsearchRequestBody;
signal?: AbortSignal;
@@ -61,28 +64,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 02f200d5435dd..a847931a49123 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 f47a42cf9d82b..2cde6d19e4c18 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 f1a6fc09ee21f..63593bbe84a08 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, map } 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 { SessionService, IScopedSessionService, ISessionService } from './session';
import { KbnServerError } from '../../../kibana_utils/server';
+import { registerBsearchRoute } from './routes/bsearch';
type StrategyMap = Record>;
@@ -137,43 +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(),
- map((response) => {
- return {
- ...response,
- ...{
- rawResponse: shimHitsTotal(response.rawResponse),
- },
- };
- }),
- 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) {
@@ -285,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 81bcb3b02e100..21560b1328840 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/angular/context_state.ts b/src/plugins/discover/public/application/angular/context_state.ts
index 73523b218df7c..e8c2f1d397ba5 100644
--- a/src/plugins/discover/public/application/angular/context_state.ts
+++ b/src/plugins/discover/public/application/angular/context_state.ts
@@ -206,7 +206,7 @@ export function getState({
}
},
// helper function just needed for testing
- flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }),
+ flushToUrl: (replace?: boolean) => stateStorage.kbnUrlControls.flush(replace),
};
}
diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js
index 41c80a717ce75..dcf86babaa5e1 100644
--- a/src/plugins/discover/public/application/angular/discover.js
+++ b/src/plugins/discover/public/application/angular/discover.js
@@ -47,8 +47,6 @@ import { popularizeField } from '../helpers/popularize_field';
import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state';
import { addFatalError } from '../../../../kibana_legacy/public';
import { METRIC_TYPE } from '@kbn/analytics';
-import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
-import { getQueryParams, removeQueryParam } from '../../../../kibana_utils/public';
import {
DEFAULT_COLUMNS_SETTING,
MODIFY_COLUMNS_ON_SWITCH,
@@ -62,6 +60,7 @@ import { getTopNavLinks } from '../components/top_nav/get_top_nav_links';
import { updateSearchSource } from '../helpers/update_search_source';
import { calcFieldCounts } from '../helpers/calc_field_counts';
import { getDefaultSort } from './doc_table/lib/get_default_sort';
+import { DiscoverSearchSessionManager } from './discover_search_session';
const services = getServices();
@@ -86,9 +85,6 @@ const fetchStatuses = {
ERROR: 'error',
};
-const getSearchSessionIdFromURL = (history) =>
- getQueryParams(history.location)[SEARCH_SESSION_ID_QUERY_PARAM];
-
const app = getAngularModule();
app.config(($routeProvider) => {
@@ -177,7 +173,9 @@ function discoverController($route, $scope, Promise) {
const { isDefault: isDefaultType } = indexPatternsUtils;
const subscriptions = new Subscription();
const refetch$ = new Subject();
+
let inspectorRequest;
+ let isChangingIndexPattern = false;
const savedSearch = $route.current.locals.savedObjects.savedSearch;
$scope.searchSource = savedSearch.searchSource;
$scope.indexPattern = resolveIndexPattern(
@@ -195,15 +193,10 @@ function discoverController($route, $scope, Promise) {
};
const history = getHistory();
- // used for restoring a search session
- let isInitialSearch = true;
-
- // search session requested a data refresh
- subscriptions.add(
- data.search.session.onRefresh$.subscribe(() => {
- refetch$.next();
- })
- );
+ const searchSessionManager = new DiscoverSearchSessionManager({
+ history,
+ session: data.search.session,
+ });
const state = getState({
getStateDefaults,
@@ -255,6 +248,7 @@ function discoverController($route, $scope, Promise) {
$scope.$evalAsync(async () => {
if (oldStatePartial.index !== newStatePartial.index) {
//in case of index pattern switch the route has currently to be reloaded, legacy
+ isChangingIndexPattern = true;
$route.reload();
return;
}
@@ -351,7 +345,12 @@ function discoverController($route, $scope, Promise) {
if (abortController) abortController.abort();
savedSearch.destroy();
subscriptions.unsubscribe();
- data.search.session.clear();
+ if (!isChangingIndexPattern) {
+ // HACK:
+ // do not clear session when changing index pattern due to how state management around it is setup
+ // it will be cleared by searchSessionManager on controller reload instead
+ data.search.session.clear();
+ }
appStateUnsubscribe();
stopStateSync();
stopSyncingGlobalStateWithUrl();
@@ -475,7 +474,8 @@ function discoverController($route, $scope, Promise) {
return (
config.get(SEARCH_ON_PAGE_LOAD_SETTING) ||
savedSearch.id !== undefined ||
- timefilter.getRefreshInterval().pause === false
+ timefilter.getRefreshInterval().pause === false ||
+ searchSessionManager.hasSearchSessionIdInURL()
);
};
@@ -486,7 +486,8 @@ function discoverController($route, $scope, Promise) {
filterManager.getFetches$(),
timefilter.getFetch$(),
timefilter.getAutoRefreshFetch$(),
- data.query.queryString.getUpdates$()
+ data.query.queryString.getUpdates$(),
+ searchSessionManager.newSearchSessionIdFromURL$
).pipe(debounceTime(100));
subscriptions.add(
@@ -512,6 +513,13 @@ function discoverController($route, $scope, Promise) {
)
);
+ subscriptions.add(
+ data.search.session.onRefresh$.subscribe(() => {
+ searchSessionManager.removeSearchSessionIdFromURL({ replace: false });
+ refetch$.next();
+ })
+ );
+
$scope.changeInterval = (interval) => {
if (interval) {
setAppState({ interval });
@@ -591,20 +599,7 @@ function discoverController($route, $scope, Promise) {
if (abortController) abortController.abort();
abortController = new AbortController();
- const searchSessionId = (() => {
- const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
- if (searchSessionIdFromURL) {
- if (isInitialSearch) {
- data.search.session.restore(searchSessionIdFromURL);
- isInitialSearch = false;
- return searchSessionIdFromURL;
- } else {
- // navigating away from background search
- removeQueryParam(history, SEARCH_SESSION_ID_QUERY_PARAM);
- }
- }
- return data.search.session.start();
- })();
+ const searchSessionId = searchSessionManager.getNextSearchSessionId();
$scope
.updateDataSource()
@@ -631,6 +626,7 @@ function discoverController($route, $scope, Promise) {
$scope.handleRefresh = function (_payload, isUpdate) {
if (isUpdate === false) {
+ searchSessionManager.removeSearchSessionIdFromURL({ replace: false });
refetch$.next();
}
};
diff --git a/src/plugins/discover/public/application/angular/discover_search_session.test.ts b/src/plugins/discover/public/application/angular/discover_search_session.test.ts
new file mode 100644
index 0000000000000..abec6aedeaf5c
--- /dev/null
+++ b/src/plugins/discover/public/application/angular/discover_search_session.test.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 { DiscoverSearchSessionManager } from './discover_search_session';
+import { createMemoryHistory } from 'history';
+import { dataPluginMock } from '../../../../data/public/mocks';
+import { DataPublicPluginStart } from '../../../../data/public';
+
+describe('DiscoverSearchSessionManager', () => {
+ const history = createMemoryHistory();
+ const session = dataPluginMock.createStartContract().search.session as jest.Mocked<
+ DataPublicPluginStart['search']['session']
+ >;
+ const searchSessionManager = new DiscoverSearchSessionManager({
+ history,
+ session,
+ });
+
+ beforeEach(() => {
+ history.push('/');
+ session.start.mockReset();
+ session.restore.mockReset();
+ session.getSessionId.mockReset();
+ session.isCurrentSession.mockReset();
+ session.isRestore.mockReset();
+ });
+
+ describe('getNextSearchSessionId', () => {
+ test('starts a new session', () => {
+ const nextId = 'id';
+ session.start.mockImplementationOnce(() => nextId);
+
+ const id = searchSessionManager.getNextSearchSessionId();
+ expect(id).toEqual(nextId);
+ expect(session.start).toBeCalled();
+ });
+
+ test('restores a session using query param from the URL', () => {
+ const nextId = 'id_from_url';
+ history.push(`/?searchSessionId=${nextId}`);
+
+ const id = searchSessionManager.getNextSearchSessionId();
+ expect(id).toEqual(nextId);
+ expect(session.restore).toBeCalled();
+ });
+
+ test('removes query param from the URL when navigating away from a restored session', () => {
+ const idFromUrl = 'id_from_url';
+ history.push(`/?searchSessionId=${idFromUrl}`);
+
+ const nextId = 'id';
+ session.start.mockImplementationOnce(() => nextId);
+ session.isCurrentSession.mockImplementationOnce(() => true);
+ session.isRestore.mockImplementationOnce(() => true);
+
+ const id = searchSessionManager.getNextSearchSessionId();
+ expect(id).toEqual(nextId);
+ expect(session.start).toBeCalled();
+ expect(history.location.search).toMatchInlineSnapshot(`""`);
+ });
+ });
+
+ describe('newSearchSessionIdFromURL$', () => {
+ test('notifies about searchSessionId changes in the URL', () => {
+ const emits: Array = [];
+
+ const sub = searchSessionManager.newSearchSessionIdFromURL$.subscribe((newId) => {
+ emits.push(newId);
+ });
+
+ history.push(`/?searchSessionId=id1`);
+ history.push(`/?searchSessionId=id1`);
+ session.isCurrentSession.mockImplementationOnce(() => true);
+ history.replace(`/?searchSessionId=id2`); // should skip current this
+ history.replace(`/`);
+ history.push(`/?searchSessionId=id1`);
+ history.push(`/`);
+
+ expect(emits).toMatchInlineSnapshot(`
+ Array [
+ "id1",
+ null,
+ "id1",
+ null,
+ ]
+ `);
+
+ sub.unsubscribe();
+ });
+ });
+});
diff --git a/src/plugins/discover/public/application/angular/discover_search_session.ts b/src/plugins/discover/public/application/angular/discover_search_session.ts
new file mode 100644
index 0000000000000..a53d7d6d2c333
--- /dev/null
+++ b/src/plugins/discover/public/application/angular/discover_search_session.ts
@@ -0,0 +1,85 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { History } from 'history';
+import { filter } from 'rxjs/operators';
+import { DataPublicPluginStart } from '../../../../data/public';
+import {
+ createQueryParamObservable,
+ getQueryParams,
+ removeQueryParam,
+} from '../../../../kibana_utils/public';
+import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
+
+export interface DiscoverSearchSessionManagerDeps {
+ history: History;
+ session: DataPublicPluginStart['search']['session'];
+}
+
+/**
+ * Helps with state management of search session and {@link SEARCH_SESSION_ID_QUERY_PARAM} in the URL
+ */
+export class DiscoverSearchSessionManager {
+ /**
+ * Notifies about `searchSessionId` changes in the URL,
+ * skips if `searchSessionId` matches current search session id
+ */
+ readonly newSearchSessionIdFromURL$ = createQueryParamObservable(
+ this.deps.history,
+ SEARCH_SESSION_ID_QUERY_PARAM
+ ).pipe(
+ filter((searchSessionId) => {
+ if (!searchSessionId) return true;
+ return !this.deps.session.isCurrentSession(searchSessionId);
+ })
+ );
+
+ constructor(private readonly deps: DiscoverSearchSessionManagerDeps) {}
+
+ /**
+ * Get next session id by either starting or restoring a session.
+ * When navigating away from the restored session {@link SEARCH_SESSION_ID_QUERY_PARAM} is removed from the URL using history.replace
+ */
+ getNextSearchSessionId() {
+ let searchSessionIdFromURL = this.getSearchSessionIdFromURL();
+ if (searchSessionIdFromURL) {
+ if (
+ this.deps.session.isRestore() &&
+ this.deps.session.isCurrentSession(searchSessionIdFromURL)
+ ) {
+ // navigating away from a restored session
+ this.removeSearchSessionIdFromURL({ replace: true });
+ searchSessionIdFromURL = undefined;
+ } else {
+ this.deps.session.restore(searchSessionIdFromURL);
+ }
+ }
+
+ return searchSessionIdFromURL ?? this.deps.session.start();
+ }
+
+ /**
+ * Removes Discovers {@link SEARCH_SESSION_ID_QUERY_PARAM} from the URL
+ * @param replace - methods to change the URL
+ */
+ removeSearchSessionIdFromURL({ replace = true }: { replace?: boolean } = { replace: true }) {
+ if (this.hasSearchSessionIdInURL()) {
+ removeQueryParam(this.deps.history, SEARCH_SESSION_ID_QUERY_PARAM, replace);
+ }
+ }
+
+ /**
+ * If there is a {@link SEARCH_SESSION_ID_QUERY_PARAM} currently in the URL
+ */
+ hasSearchSessionIdInURL(): boolean {
+ return !!this.getSearchSessionIdFromURL();
+ }
+
+ private getSearchSessionIdFromURL = () =>
+ getQueryParams(this.deps.history.location)[SEARCH_SESSION_ID_QUERY_PARAM] as string | undefined;
+}
diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts
index c769e263655ab..65a8dded11092 100644
--- a/src/plugins/discover/public/application/angular/discover_state.ts
+++ b/src/plugins/discover/public/application/angular/discover_state.ts
@@ -200,7 +200,7 @@ export function getState({
setState(appStateContainerModified, defaultState);
},
getPreviousAppState: () => previousAppState,
- flushToUrl: () => stateStorage.flush(),
+ flushToUrl: () => stateStorage.kbnUrlControls.flush(),
isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()),
};
}
diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx
new file mode 100644
index 0000000000000..f9428e30569f7
--- /dev/null
+++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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 { findTestSubject } from '@elastic/eui/lib/test';
+import { mountWithIntl } from '@kbn/test/jest';
+import { DiscoverGridFlyout } from './discover_grid_flyout';
+import { esHits } from '../../../__mocks__/es_hits';
+import { createFilterManagerMock } from '../../../../../data/public/query/filter_manager/filter_manager.mock';
+import { indexPatternMock } from '../../../__mocks__/index_pattern';
+import { DiscoverServices } from '../../../build_services';
+import { DocViewsRegistry } from '../../doc_views/doc_views_registry';
+import { setDocViewsRegistry } from '../../../kibana_services';
+import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield';
+
+describe('Discover flyout', function () {
+ setDocViewsRegistry(new DocViewsRegistry());
+
+ it('should be rendered correctly using an index pattern without timefield', async () => {
+ const onClose = jest.fn();
+ const component = mountWithIntl(
+
+ );
+
+ const url = findTestSubject(component, 'docTableRowAction').prop('href');
+ expect(url).toMatchInlineSnapshot(`"#/doc/the-index-pattern-id/i?id=1"`);
+ findTestSubject(component, 'euiFlyoutCloseButton').simulate('click');
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('should be rendered correctly using an index pattern with timefield', async () => {
+ const onClose = jest.fn();
+ const component = mountWithIntl(
+
+ );
+
+ const actions = findTestSubject(component, 'docTableRowAction');
+ expect(actions.length).toBe(2);
+ expect(actions.first().prop('href')).toMatchInlineSnapshot(
+ `"#/doc/index-pattern-with-timefield-id/i?id=1"`
+ );
+ expect(actions.last().prop('href')).toMatchInlineSnapshot(
+ `"#/context/index-pattern-with-timefield-id/1?_g=(filters:!())&_a=(columns:!(date),filters:!())"`
+ );
+ findTestSubject(component, 'euiFlyoutCloseButton').simulate('click');
+ expect(onClose).toHaveBeenCalled();
+ });
+});
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 1b7406496bb81..4d522f47ea87f 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 1394ceab1dd18..ea16b81615e42 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 62478f1d2830f..1d780a5573e2a 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 0000000000000..bef7bc394a6cc
--- /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 354cf1d504b28..f859e0728269a 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 0000000000000..89e83586dc115
--- /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/docs/state_sync/storages/kbn_url_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md
index ec27895eed666..36c7d7119ffe5 100644
--- a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md
+++ b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md
@@ -96,11 +96,11 @@ setTimeout(() => {
}, 0);
```
-For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis:
+For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` exposes `kbnUrlStateStorage.kbnUrlControls` that exposes these advanced apis:
-- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates.
- `replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened
-- `kbnUrlStateStorage.cancel()` - cancels any pending updates
+- `kbnUrlStateStorage.kbnUrlControls.flush({replace: boolean})` - allows to synchronously apply any pending updates.
+ `replace` option allows using `history.replace()` instead of `history.push()`.
+- `kbnUrlStateStorage.kbnUrlControls.cancel()` - cancels any pending updates.
### Sharing one `kbnUrlStateStorage` instance
diff --git a/src/plugins/kibana_utils/public/history/history_observable.test.ts b/src/plugins/kibana_utils/public/history/history_observable.test.ts
new file mode 100644
index 0000000000000..818c0d7739283
--- /dev/null
+++ b/src/plugins/kibana_utils/public/history/history_observable.test.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 {
+ createHistoryObservable,
+ createQueryParamObservable,
+ createQueryParamsObservable,
+} from './history_observable';
+import { createMemoryHistory, History } from 'history';
+import { ParsedQuery } from 'query-string';
+
+let history: History;
+
+beforeEach(() => {
+ history = createMemoryHistory();
+});
+
+test('createHistoryObservable', () => {
+ const obs$ = createHistoryObservable(history);
+ const emits: string[] = [];
+ obs$.subscribe(({ location }) => {
+ emits.push(location.pathname + location.search);
+ });
+
+ history.push('/test');
+ history.push('/');
+
+ expect(emits.length).toEqual(2);
+ expect(emits).toMatchInlineSnapshot(`
+ Array [
+ "/test",
+ "/",
+ ]
+ `);
+});
+
+test('createQueryParamsObservable', () => {
+ const obs$ = createQueryParamsObservable(history);
+ const emits: ParsedQuery[] = [];
+ obs$.subscribe((params) => {
+ emits.push(params);
+ });
+
+ history.push('/test');
+ history.push('/test?foo=bar');
+ history.push('/?foo=bar');
+ history.push('/test?foo=bar&foo1=bar1');
+
+ expect(emits.length).toEqual(2);
+ expect(emits).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "foo": "bar",
+ },
+ Object {
+ "foo": "bar",
+ "foo1": "bar1",
+ },
+ ]
+ `);
+});
+
+test('createQueryParamObservable', () => {
+ const obs$ = createQueryParamObservable(history, 'foo');
+ const emits: unknown[] = [];
+ obs$.subscribe((param) => {
+ emits.push(param);
+ });
+
+ history.push('/test');
+ history.push('/test?foo=bar');
+ history.push('/?foo=bar');
+ history.push('/test?foo=baaaar&foo1=bar1');
+ history.push('/test?foo1=bar1');
+
+ expect(emits.length).toEqual(3);
+ expect(emits).toMatchInlineSnapshot(`
+ Array [
+ "bar",
+ "baaaar",
+ null,
+ ]
+ `);
+});
diff --git a/src/plugins/kibana_utils/public/history/history_observable.ts b/src/plugins/kibana_utils/public/history/history_observable.ts
new file mode 100644
index 0000000000000..f02a5e340b1a0
--- /dev/null
+++ b/src/plugins/kibana_utils/public/history/history_observable.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 { Action, History, Location } from 'history';
+import { Observable } from 'rxjs';
+import { ParsedQuery } from 'query-string';
+import deepEqual from 'fast-deep-equal';
+import { map } from 'rxjs/operators';
+import { getQueryParams } from './get_query_params';
+import { distinctUntilChangedWithInitialValue } from '../../common';
+
+/**
+ * Convert history.listen into an observable
+ * @param history - {@link History} instance
+ */
+export function createHistoryObservable(
+ history: History
+): Observable<{ location: Location; action: Action }> {
+ return new Observable((observer) => {
+ const unlisten = history.listen((location, action) => observer.next({ location, action }));
+ return () => {
+ unlisten();
+ };
+ });
+}
+
+/**
+ * Create an observable that emits every time any of query params change.
+ * Uses deepEqual check.
+ * @param history - {@link History} instance
+ */
+export function createQueryParamsObservable(history: History): Observable {
+ return createHistoryObservable(history).pipe(
+ map(({ location }) => ({ ...getQueryParams(location) })),
+ distinctUntilChangedWithInitialValue({ ...getQueryParams(history.location) }, deepEqual)
+ );
+}
+
+/**
+ * Create an observable that emits every time _paramKey_ changes
+ * @param history - {@link History} instance
+ * @param paramKey - query param key to observe
+ */
+export function createQueryParamObservable(
+ history: History,
+ paramKey: string
+): Observable {
+ return createQueryParamsObservable(history).pipe(
+ map((params) => (params[paramKey] ?? null) as Param | null),
+ distinctUntilChangedWithInitialValue(
+ (getQueryParams(history.location)[paramKey] ?? null) as Param | null,
+ deepEqual
+ )
+ );
+}
diff --git a/src/plugins/kibana_utils/public/history/index.ts b/src/plugins/kibana_utils/public/history/index.ts
index 4b1b610d560e2..b2ac9ed6c739e 100644
--- a/src/plugins/kibana_utils/public/history/index.ts
+++ b/src/plugins/kibana_utils/public/history/index.ts
@@ -9,3 +9,4 @@
export { removeQueryParam } from './remove_query_param';
export { redirectWhenMissing } from './redirect_when_missing';
export { getQueryParams } from './get_query_params';
+export * from './history_observable';
diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts
index fa9cf5a52371d..29936da0117c1 100644
--- a/src/plugins/kibana_utils/public/index.ts
+++ b/src/plugins/kibana_utils/public/index.ts
@@ -68,7 +68,14 @@ export {
StopSyncStateFnType,
} from './state_sync';
export { Configurable, CollectConfigProps } from './ui';
-export { removeQueryParam, redirectWhenMissing, getQueryParams } from './history';
+export {
+ removeQueryParam,
+ redirectWhenMissing,
+ getQueryParams,
+ createQueryParamsObservable,
+ createHistoryObservable,
+ createQueryParamObservable,
+} from './history';
export { applyDiff } from './state_management/utils/diff_object';
export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter';
diff --git a/src/plugins/kibana_utils/public/state_sync/public.api.md b/src/plugins/kibana_utils/public/state_sync/public.api.md
index a4dfea82cdb59..5524563c034a8 100644
--- a/src/plugins/kibana_utils/public/state_sync/public.api.md
+++ b/src/plugins/kibana_utils/public/state_sync/public.api.md
@@ -22,14 +22,12 @@ export const createSessionStorageStateStorage: (storage?: Storage) => ISessionSt
// @public
export interface IKbnUrlStateStorage extends IStateStorage {
- cancel: () => void;
// (undocumented)
change$: (key: string) => Observable;
- flush: (opts?: {
- replace?: boolean;
- }) => boolean;
// (undocumented)
get: (key: string) => State | null;
+ // Warning: (ae-forgotten-export) The symbol "IKbnUrlControls" needs to be exported by the entry point index.d.ts
+ kbnUrlControls: IKbnUrlControls;
// (undocumented)
set: (key: string, state: State, opts?: {
replace: boolean;
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
index c7f04bc9cdbe3..890de8f6ed6a1 100644
--- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
@@ -255,7 +255,7 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
- urlSyncStrategy.flush();
+ urlSyncStrategy.kbnUrlControls.flush();
expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot(
@@ -290,7 +290,7 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
- urlSyncStrategy.cancel();
+ urlSyncStrategy.kbnUrlControls.cancel();
expect(history.length).toBe(startHistoryLength);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts
index fbd3c3f933791..037c6f9fc666d 100644
--- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts
@@ -39,11 +39,11 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
- expect(urlStateStorage.flush()).toBe(true);
+ expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
- expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update
+ expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update
});
it('should cancel url updates', async () => {
@@ -51,7 +51,7 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
- urlStateStorage.cancel();
+ urlStateStorage.kbnUrlControls.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(urlStateStorage.get(key)).toEqual(null);
@@ -215,11 +215,11 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
- expect(urlStateStorage.flush()).toBe(true);
+ expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/#?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
- expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update
+ expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update
});
it('should cancel url updates', async () => {
@@ -227,7 +227,7 @@ describe('KbnUrlStateStorage', () => {
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
- urlStateStorage.cancel();
+ urlStateStorage.kbnUrlControls.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`);
expect(urlStateStorage.get(key)).toEqual(null);
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts
index 700420447bf4f..0935ecd20111f 100644
--- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts
@@ -13,6 +13,7 @@ import { IStateStorage } from './types';
import {
createKbnUrlControls,
getStateFromKbnUrl,
+ IKbnUrlControls,
setStateToKbnUrl,
} from '../../state_management/url';
@@ -39,16 +40,9 @@ export interface IKbnUrlStateStorage extends IStateStorage {
change$: (key: string) => Observable;
/**
- * cancels any pending url updates
+ * Lower level wrapper around history library that handles batching multiple URL updates into one history change
*/
- cancel: () => void;
-
- /**
- * Synchronously runs any pending url updates, returned boolean indicates if change occurred.
- * @param opts: {replace? boolean} - allows to specify if push or replace should be used for flushing update
- * @returns boolean - indicates if there was an update to flush
- */
- flush: (opts?: { replace?: boolean }) => boolean;
+ kbnUrlControls: IKbnUrlControls;
}
/**
@@ -114,11 +108,6 @@ export const createKbnUrlStateStorage = (
}),
share()
),
- flush: ({ replace = false }: { replace?: boolean } = {}) => {
- return !!url.flush(replace);
- },
- cancel() {
- url.cancel();
- },
+ kbnUrlControls: url,
};
};
diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts
index f95ffe5c3d7b6..821118ea4640d 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 664f34ca7ad51..01e80cfc7184d 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/maps_oss/tsconfig.json b/src/plugins/legacy_export/tsconfig.json
similarity index 65%
rename from src/plugins/maps_oss/tsconfig.json
rename to src/plugins/legacy_export/tsconfig.json
index 03c30c3c49fd3..ec006d492499e 100644
--- a/src/plugins/maps_oss/tsconfig.json
+++ b/src/plugins/legacy_export/tsconfig.json
@@ -7,8 +7,10 @@
"declaration": true,
"declarationMap": true
},
- "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"],
+ "include": [
+ "server/**/*",
+ ],
"references": [
- { "path": "../visualizations/tsconfig.json" },
+ { "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 047423a0a9036..0000000000000
--- 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 0000000000000..35b80e3634534
--- /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 0000000000000..cb9991e216019
--- /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 8aaf9be6ef5c6..b156ef4ae764c 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 58a70c9db7dd5..7c7b12f52ab5f 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 0000000000000..2044ecdd713e1
--- /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 0000000000000..b1bf9ed695842
--- /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 baf40a1ea0ae4..586ddd1320641 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 cbc1d0eb04e27..5d3618b034656 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 0000000000000..01b143e612461
--- /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 0000000000000..59f1f9fd7a43b
--- /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