mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Discover] Implement Discover customization framework (#158603)
## Summary
This PR includes the initial implementation of the Discover
customization framework based on the `2023-04 Discover Customizations`
RFC.

Notes:
- I've included two initial extension points in this PR: `top_nav` and
`search_bar`. To my knowledge, these are the ones o11y want to start
with, but we don't yet have product alignment on these decisions. ~~I've
left them in for now for testing purposes, but I'll need to update this
PR before merging to either add tests for these extensions points if we
decide to include them, or remove them if we decide not to include
them.~~ Tests have now been added for these customizations.
- I'm planning to open a separate PR with documentation about the
framework once this is merged, but merging this first will unblock o11y.
- In order to enable customization profiles, Discover has been updated
to user locators for all of its navigation, which will allow the current
profile to be maintained when navigating between routes. This is because
the current customization profile is stored in the URL path as
`/p/{profile_name}/{discover_route}`.
Resolves #158625.
### Checklist
- [ ] ~Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~
- [ ]
~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials~
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] ~Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~
- [ ] ~If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~
- [ ] ~This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
4172236eb2
commit
b78c798971
67 changed files with 2245 additions and 204 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -325,6 +325,7 @@ packages/kbn-dev-proc-runner @elastic/kibana-operations
|
|||
src/plugins/dev_tools @elastic/platform-deployment-management
|
||||
packages/kbn-dev-utils @elastic/kibana-operations
|
||||
examples/developer_examples @elastic/appex-sharedux
|
||||
examples/discover_customization_examples @elastic/kibana-data-discovery
|
||||
x-pack/plugins/discover_enhanced @elastic/kibana-data-discovery
|
||||
src/plugins/discover @elastic/kibana-data-discovery
|
||||
packages/kbn-doc-links @elastic/kibana-docs
|
||||
|
|
11
examples/discover_customization_examples/kibana.jsonc
Normal file
11
examples/discover_customization_examples/kibana.jsonc
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/discover-customization-examples-plugin",
|
||||
"owner": "@elastic/kibana-data-discovery",
|
||||
"plugin": {
|
||||
"id": "discoverCustomizationExamples",
|
||||
"server": false,
|
||||
"browser": true,
|
||||
"requiredPlugins": ["developerExamples", "discover"]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 331 KiB |
13
examples/discover_customization_examples/public/index.ts
Normal file
13
examples/discover_customization_examples/public/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DiscoverCustomizationExamplesPlugin } from './plugin';
|
||||
|
||||
export function plugin() {
|
||||
return new DiscoverCustomizationExamplesPlugin();
|
||||
}
|
236
examples/discover_customization_examples/public/plugin.tsx
Normal file
236
examples/discover_customization_examples/public/plugin.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiContextMenu,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
EuiWrappingPopover,
|
||||
} from '@elastic/eui';
|
||||
import {
|
||||
AppNavLinkStatus,
|
||||
CoreSetup,
|
||||
CoreStart,
|
||||
Plugin,
|
||||
SimpleSavedObject,
|
||||
} from '@kbn/core/public';
|
||||
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import type { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public';
|
||||
import { noop } from 'lodash';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import image from './discover_customization_examples.png';
|
||||
|
||||
export interface DiscoverCustomizationExamplesSetupPlugins {
|
||||
developerExamples: DeveloperExamplesSetup;
|
||||
discover: DiscoverSetup;
|
||||
}
|
||||
|
||||
export interface DiscoverCustomizationExamplesStartPlugins {
|
||||
discover: DiscoverStart;
|
||||
}
|
||||
|
||||
const PLUGIN_ID = 'discoverCustomizationExamples';
|
||||
const PLUGIN_NAME = 'Discover Customizations';
|
||||
|
||||
export class DiscoverCustomizationExamplesPlugin implements Plugin {
|
||||
setup(core: CoreSetup, plugins: DiscoverCustomizationExamplesSetupPlugins) {
|
||||
core.application.register({
|
||||
id: PLUGIN_ID,
|
||||
title: PLUGIN_NAME,
|
||||
navLinkStatus: AppNavLinkStatus.hidden,
|
||||
mount() {
|
||||
plugins.discover?.locator?.navigate(
|
||||
{ profile: 'customization-examples' },
|
||||
{ replace: true }
|
||||
);
|
||||
return noop;
|
||||
},
|
||||
});
|
||||
|
||||
plugins.developerExamples.register({
|
||||
appId: PLUGIN_ID,
|
||||
title: PLUGIN_NAME,
|
||||
description: 'Example plugin that uses the Discover customization framework.',
|
||||
image,
|
||||
});
|
||||
}
|
||||
|
||||
start(core: CoreStart, plugins: DiscoverCustomizationExamplesStartPlugins) {
|
||||
const { discover } = plugins;
|
||||
|
||||
let isOptionsOpen = false;
|
||||
const optionsContainer = document.createElement('div');
|
||||
const closeOptionsPopover = () => {
|
||||
ReactDOM.unmountComponentAtNode(optionsContainer);
|
||||
document.body.removeChild(optionsContainer);
|
||||
isOptionsOpen = false;
|
||||
};
|
||||
|
||||
discover.customize('customization-examples', async ({ customizations, stateContainer }) => {
|
||||
customizations.set({
|
||||
id: 'top_nav',
|
||||
defaultMenu: {
|
||||
newItem: { disabled: true },
|
||||
openItem: { disabled: true },
|
||||
shareItem: { order: 200 },
|
||||
alertsItem: { disabled: true },
|
||||
inspectItem: { disabled: true },
|
||||
saveItem: { order: 400 },
|
||||
},
|
||||
getMenuItems: () => [
|
||||
{
|
||||
data: {
|
||||
id: 'options',
|
||||
label: 'Options',
|
||||
iconType: 'arrowDown',
|
||||
iconSide: 'right',
|
||||
testId: 'customOptionsButton',
|
||||
run: (anchorElement: HTMLElement) => {
|
||||
if (isOptionsOpen) {
|
||||
closeOptionsPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
isOptionsOpen = true;
|
||||
document.body.appendChild(optionsContainer);
|
||||
|
||||
const element = (
|
||||
<EuiWrappingPopover
|
||||
ownFocus
|
||||
button={anchorElement}
|
||||
isOpen={true}
|
||||
panelPaddingSize="s"
|
||||
closePopover={closeOptionsPopover}
|
||||
>
|
||||
<EuiContextMenu
|
||||
size="s"
|
||||
initialPanelId={0}
|
||||
panels={[
|
||||
{
|
||||
id: 0,
|
||||
items: [
|
||||
{
|
||||
name: 'Create new',
|
||||
icon: 'plusInCircle',
|
||||
onClick: () => alert('Create new clicked'),
|
||||
},
|
||||
{
|
||||
name: 'Make a copy',
|
||||
icon: 'copy',
|
||||
onClick: () => alert('Make a copy clicked'),
|
||||
},
|
||||
{
|
||||
name: 'Manage saved searches',
|
||||
icon: 'gear',
|
||||
onClick: () => alert('Manage saved searches clicked'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
data-test-subj="customOptionsPopover"
|
||||
/>
|
||||
</EuiWrappingPopover>
|
||||
);
|
||||
|
||||
ReactDOM.render(element, optionsContainer);
|
||||
},
|
||||
},
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'documentExplorer',
|
||||
label: 'Document explorer',
|
||||
iconType: 'discoverApp',
|
||||
testId: 'documentExplorerButton',
|
||||
run: () => {
|
||||
discover.locator?.navigate({});
|
||||
},
|
||||
},
|
||||
order: 300,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
customizations.set({
|
||||
id: 'search_bar',
|
||||
CustomDataViewPicker: () => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const togglePopover = () => setIsPopoverOpen((open) => !open);
|
||||
const closePopover = () => setIsPopoverOpen(false);
|
||||
const [savedSearches, setSavedSearches] = useState<
|
||||
Array<SimpleSavedObject<{ title: string }>>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
core.savedObjects.client
|
||||
.find<{ title: string }>({ type: 'search' })
|
||||
.then((response) => {
|
||||
setSavedSearches(response.savedObjects);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentSavedSearch = useObservable(
|
||||
stateContainer.savedSearchState.getCurrent$(),
|
||||
stateContainer.savedSearchState.getState()
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButton
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
fullWidth
|
||||
onClick={togglePopover}
|
||||
data-test-subj="logsViewSelectorButton"
|
||||
>
|
||||
{currentSavedSearch.title ?? 'None selected'}
|
||||
</EuiButton>
|
||||
}
|
||||
anchorClassName="eui-fullWidth"
|
||||
isOpen={isPopoverOpen}
|
||||
panelPaddingSize="none"
|
||||
closePopover={closePopover}
|
||||
>
|
||||
<EuiContextMenu
|
||||
size="s"
|
||||
initialPanelId={0}
|
||||
panels={[
|
||||
{
|
||||
id: 0,
|
||||
title: 'Saved logs views',
|
||||
items: savedSearches.map((savedSearch) => ({
|
||||
name: savedSearch.get('title'),
|
||||
onClick: () => stateContainer.actions.onOpenSavedSearch(savedSearch.id),
|
||||
icon: savedSearch.id === currentSavedSearch.id ? 'check' : 'empty',
|
||||
'data-test-subj': `logsViewSelectorOption-${savedSearch.attributes.title.replace(
|
||||
/[^a-zA-Z0-9]/g,
|
||||
''
|
||||
)}`,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Cleaning up Logs explorer customizations');
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
13
examples/discover_customization_examples/tsconfig.json
Normal file
13
examples/discover_customization_examples/tsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["common/**/*", "public/**/*", "server/**/*", "../../typings/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/discover-plugin",
|
||||
"@kbn/developer-examples-plugin",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
|
@ -367,6 +367,7 @@
|
|||
"@kbn/default-nav-ml": "link:packages/default-nav/ml",
|
||||
"@kbn/dev-tools-plugin": "link:src/plugins/dev_tools",
|
||||
"@kbn/developer-examples-plugin": "link:examples/developer_examples",
|
||||
"@kbn/discover-customization-examples-plugin": "link:examples/discover_customization_examples",
|
||||
"@kbn/discover-enhanced-plugin": "link:x-pack/plugins/discover_enhanced",
|
||||
"@kbn/discover-plugin": "link:src/plugins/discover",
|
||||
"@kbn/doc-links": "link:packages/kbn-doc-links",
|
||||
|
|
9
src/plugins/discover/common/customizations/index.ts
Normal file
9
src/plugins/discover/common/customizations/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './utils';
|
73
src/plugins/discover/common/customizations/utils.test.ts
Normal file
73
src/plugins/discover/common/customizations/utils.test.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { addProfile, getProfile } from './utils';
|
||||
|
||||
describe('Discover customization utils', () => {
|
||||
describe('addProfile', () => {
|
||||
it('should add profile to path', () => {
|
||||
expect(addProfile('/root', 'test')).toEqual('/root/p/test');
|
||||
});
|
||||
|
||||
it('should add profile to path with trailing slash', () => {
|
||||
expect(addProfile('/root/', 'test')).toEqual('/root/p/test/');
|
||||
});
|
||||
|
||||
it('should trim path', () => {
|
||||
expect(addProfile(' /root ', 'test')).toEqual('/root/p/test');
|
||||
});
|
||||
|
||||
it('should work with empty path', () => {
|
||||
expect(addProfile('', 'test')).toEqual('/p/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('should return profile from path', () => {
|
||||
expect(getProfile('/p/test/subpath')).toEqual({
|
||||
profile: 'test',
|
||||
isProfileRootPath: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return profile from path with trailing slash', () => {
|
||||
expect(getProfile('/p/test/subpath/')).toEqual({
|
||||
profile: 'test',
|
||||
isProfileRootPath: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return profile from root path', () => {
|
||||
expect(getProfile('/p/test')).toEqual({
|
||||
profile: 'test',
|
||||
isProfileRootPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return profile from root path with trailing slash', () => {
|
||||
expect(getProfile('/p/test/')).toEqual({
|
||||
profile: 'test',
|
||||
isProfileRootPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if profile is not in path', () => {
|
||||
expect(getProfile('/root')).toEqual({
|
||||
profile: undefined,
|
||||
isProfileRootPath: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if path is empty', () => {
|
||||
expect(getProfile('')).toEqual({
|
||||
profile: undefined,
|
||||
isProfileRootPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
27
src/plugins/discover/common/customizations/utils.ts
Normal file
27
src/plugins/discover/common/customizations/utils.ts
Normal file
|
@ -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
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
export const addProfile = (path: string, profile: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
const hasSlash = trimmedPath.endsWith('/');
|
||||
|
||||
return `${trimmedPath}${hasSlash ? '' : '/'}p/${profile}${hasSlash ? '/' : ''}`;
|
||||
};
|
||||
|
||||
export const getProfile = (path: string) => {
|
||||
const match = matchPath<{ profile?: string }>(path, {
|
||||
path: '/p/:profile',
|
||||
});
|
||||
|
||||
return {
|
||||
profile: match?.params.profile,
|
||||
isProfileRootPath: match?.isExact ?? false,
|
||||
};
|
||||
};
|
|
@ -15,6 +15,7 @@ import { mockStorage } from '@kbn/kibana-utils-plugin/public/storage/hashed_item
|
|||
import { FilterStateStore } from '@kbn/es-query';
|
||||
import { DiscoverAppLocatorDefinition } from './locator';
|
||||
import { SerializableRecord } from '@kbn/utility-types';
|
||||
import { addProfile } from './customizations';
|
||||
|
||||
const dataViewId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002';
|
||||
const savedSearchId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d';
|
||||
|
@ -43,8 +44,8 @@ describe('Discover url generator', () => {
|
|||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(app).toBe('discover');
|
||||
expect(_a).toEqual({});
|
||||
expect(_g).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('can create a link to a saved search in Discover', async () => {
|
||||
|
@ -53,8 +54,15 @@ describe('Discover url generator', () => {
|
|||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(path.startsWith(`#/view/${savedSearchId}`)).toBe(true);
|
||||
expect(_a).toEqual({});
|
||||
expect(_g).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('can specify profile', async () => {
|
||||
const { locator } = await setup();
|
||||
const { path } = await locator.getLocation({ profile: 'test', dataViewId: '123' });
|
||||
|
||||
expect(path).toBe(`${addProfile('#/', 'test')}?_a=(index:'123')`);
|
||||
});
|
||||
|
||||
test('can specify specific data view', async () => {
|
||||
|
@ -65,7 +73,7 @@ describe('Discover url generator', () => {
|
|||
expect(_a).toEqual({
|
||||
index: dataViewId,
|
||||
});
|
||||
expect(_g).toEqual({});
|
||||
expect(_g).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('can specify specific time range', async () => {
|
||||
|
@ -75,7 +83,7 @@ describe('Discover url generator', () => {
|
|||
});
|
||||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(_a).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual({
|
||||
time: {
|
||||
from: 'now-15m',
|
||||
|
@ -101,7 +109,7 @@ describe('Discover url generator', () => {
|
|||
query: 'foo',
|
||||
},
|
||||
});
|
||||
expect(_g).toEqual({});
|
||||
expect(_g).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('can specify local and global filters', async () => {
|
||||
|
@ -172,7 +180,7 @@ describe('Discover url generator', () => {
|
|||
});
|
||||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(_a).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual({
|
||||
refreshInterval: {
|
||||
pause: false,
|
||||
|
@ -191,7 +199,7 @@ describe('Discover url generator', () => {
|
|||
});
|
||||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(_a).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual({
|
||||
time: {
|
||||
from: 'now-3h',
|
||||
|
@ -206,7 +214,7 @@ describe('Discover url generator', () => {
|
|||
searchSessionId: '__test__',
|
||||
});
|
||||
|
||||
expect(path).toMatchInlineSnapshot(`"#/?_g=()&_a=()&searchSessionId=__test__"`);
|
||||
expect(path).toMatchInlineSnapshot(`"#/?searchSessionId=__test__"`);
|
||||
expect(path).toContain('__test__');
|
||||
});
|
||||
|
||||
|
@ -220,7 +228,7 @@ describe('Discover url generator', () => {
|
|||
});
|
||||
|
||||
expect(path).toMatchInlineSnapshot(
|
||||
`"#/?_g=()&_a=(columns:!(_source),interval:auto,savedQuery:__savedQueryId__,sort:!(!('timestamp,%20asc')))"`
|
||||
`"#/?_a=(columns:!(_source),interval:auto,savedQuery:__savedQueryId__,sort:!(!('timestamp,%20asc')))"`
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'
|
|||
import { DataViewSpec } from '@kbn/data-views-plugin/common';
|
||||
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common';
|
||||
import { VIEW_MODE } from './constants';
|
||||
import { addProfile } from './customizations';
|
||||
|
||||
export const DISCOVER_APP_LOCATOR = 'DISCOVER_APP_LOCATOR';
|
||||
|
||||
|
@ -99,6 +100,10 @@ export interface DiscoverAppLocatorParams extends SerializableRecord {
|
|||
* Used when navigating to particular alert results
|
||||
*/
|
||||
isAlertResults?: boolean;
|
||||
/**
|
||||
* The Discover profile to use
|
||||
*/
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
export type DiscoverAppLocator = LocatorPublic<DiscoverAppLocatorParams>;
|
||||
|
@ -141,6 +146,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition<DiscoverA
|
|||
hideAggregatedPreview,
|
||||
breakdownField,
|
||||
isAlertResults,
|
||||
profile,
|
||||
} = params;
|
||||
const savedSearchPath = savedSearchId ? `view/${encodeURIComponent(savedSearchId)}` : '';
|
||||
const appState: {
|
||||
|
@ -178,12 +184,29 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition<DiscoverA
|
|||
if (dataViewSpec) state.dataViewSpec = dataViewSpec;
|
||||
if (isAlertResults) state.isAlertResults = isAlertResults;
|
||||
|
||||
let path = `#/${savedSearchPath}`;
|
||||
path = this.deps.setStateToKbnUrl<GlobalQueryStateFromUrl>('_g', queryState, { useHash }, path);
|
||||
path = this.deps.setStateToKbnUrl('_a', appState, { useHash }, path);
|
||||
let path = '#/';
|
||||
|
||||
if (profile) {
|
||||
path = addProfile(path, profile);
|
||||
}
|
||||
|
||||
path = `${path}${savedSearchPath}`;
|
||||
|
||||
if (searchSessionId) {
|
||||
path = `${path}&searchSessionId=${searchSessionId}`;
|
||||
path = `${path}?searchSessionId=${searchSessionId}`;
|
||||
}
|
||||
|
||||
if (Object.keys(queryState).length) {
|
||||
path = this.deps.setStateToKbnUrl<GlobalQueryStateFromUrl>(
|
||||
'_g',
|
||||
queryState,
|
||||
{ useHash },
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(appState).length) {
|
||||
path = this.deps.setStateToKbnUrl('_a', appState, { useHash }, path);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -23,6 +23,7 @@ import { LocalStorageMock } from '../../__mocks__/local_storage_mock';
|
|||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import type { HistoryLocationState } from '../../build_services';
|
||||
import { createSearchSessionMock } from '../../__mocks__/search_session';
|
||||
|
||||
const mockFilterManager = createFilterManagerMock();
|
||||
const mockNavigationPlugin = {
|
||||
|
@ -30,6 +31,7 @@ const mockNavigationPlugin = {
|
|||
};
|
||||
|
||||
describe('ContextApp test', () => {
|
||||
const { history } = createSearchSessionMock();
|
||||
const services = {
|
||||
data: {
|
||||
...dataPluginMock.createStartContract(),
|
||||
|
@ -57,7 +59,7 @@ describe('ContextApp test', () => {
|
|||
notifications: { toasts: [] },
|
||||
theme: { theme$: themeServiceMock.createStartContract().theme$ },
|
||||
},
|
||||
history: () => {},
|
||||
history: () => history,
|
||||
fieldFormats: {
|
||||
getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })),
|
||||
getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })),
|
||||
|
|
|
@ -69,14 +69,14 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) =>
|
|||
|
||||
useEffect(() => {
|
||||
services.chrome.setBreadcrumbs([
|
||||
...getRootBreadcrumbs(referrer),
|
||||
...getRootBreadcrumbs({ breadcrumb: referrer, services }),
|
||||
{
|
||||
text: i18n.translate('discover.context.breadcrumb', {
|
||||
defaultMessage: 'Surrounding documents',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
}, [locator, referrer, services.chrome]);
|
||||
}, [locator, referrer, services]);
|
||||
|
||||
useExecutionContext(core.executionContext, {
|
||||
type: 'application',
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { addProfile } from '../../../../common/customizations';
|
||||
import { getStatesFromKbnUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import { DiscoverContextAppLocatorDefinition } from './locator';
|
||||
|
||||
|
@ -49,8 +50,8 @@ describe('Discover context url generator', () => {
|
|||
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']);
|
||||
|
||||
expect(app).toBe('discover');
|
||||
expect(_a).toEqual({});
|
||||
expect(_g).toEqual({});
|
||||
expect(_a).toEqual(undefined);
|
||||
expect(_g).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should fill history state for context view', async () => {
|
||||
|
@ -72,6 +73,18 @@ describe('Discover context url generator', () => {
|
|||
expect(_g).toEqual({ filters: [] });
|
||||
});
|
||||
|
||||
test('can specify profile', async () => {
|
||||
const { locator } = await setup();
|
||||
const { path } = await locator.getLocation({
|
||||
profile: 'test',
|
||||
index: dataViewId,
|
||||
rowId: 'mock-row-id',
|
||||
referrer: 'mock-referrer',
|
||||
});
|
||||
|
||||
expect(path).toBe(`${addProfile('#/', 'test')}context/${dataViewId}/mock-row-id`);
|
||||
});
|
||||
|
||||
test('when useHash set to false, sets data view ID in the generated URL', async () => {
|
||||
const { locator } = await setup();
|
||||
const { path } = await locator.getLocation({
|
||||
|
|
|
@ -12,6 +12,8 @@ import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public';
|
|||
import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
|
||||
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
|
||||
import { addProfile } from '../../../../common/customizations';
|
||||
|
||||
export const DISCOVER_CONTEXT_APP_LOCATOR = 'DISCOVER_CONTEXT_APP_LOCATOR';
|
||||
|
||||
export interface DiscoverContextAppLocatorParams extends SerializableRecord {
|
||||
|
@ -20,6 +22,7 @@ export interface DiscoverContextAppLocatorParams extends SerializableRecord {
|
|||
columns?: string[];
|
||||
filters?: Filter[];
|
||||
referrer: string; // discover main view url
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
export type DiscoverContextAppLocator = LocatorPublic<DiscoverContextAppLocatorParams>;
|
||||
|
@ -62,9 +65,21 @@ export class DiscoverContextAppLocatorDefinition
|
|||
dataViewId = index;
|
||||
}
|
||||
|
||||
let path = `#/context/${dataViewId}/${rowId}`;
|
||||
path = setStateToKbnUrl<GlobalQueryStateFromUrl>('_g', queryState, { useHash }, path);
|
||||
path = setStateToKbnUrl('_a', appState, { useHash }, path);
|
||||
let path = '#/';
|
||||
|
||||
if (params.profile) {
|
||||
path = addProfile(path, params.profile);
|
||||
}
|
||||
|
||||
path = `${path}context/${dataViewId}/${rowId}`;
|
||||
|
||||
if (Object.keys(queryState).length) {
|
||||
path = setStateToKbnUrl<GlobalQueryStateFromUrl>('_g', queryState, { useHash }, path);
|
||||
}
|
||||
|
||||
if (Object.keys(appState).length) {
|
||||
path = setStateToKbnUrl('_a', appState, { useHash }, path);
|
||||
}
|
||||
|
||||
return {
|
||||
app: 'discover',
|
||||
|
|
|
@ -6,49 +6,193 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Redirect, RouteProps } from 'react-router-dom';
|
||||
import { Route } from '@kbn/shared-ux-router';
|
||||
import { createSearchSessionMock } from '../__mocks__/search_session';
|
||||
import { discoverServiceMock as mockDiscoverServices } from '../__mocks__/services';
|
||||
import { discoverRouter } from './discover_router';
|
||||
import { CustomDiscoverRoutes, DiscoverRouter, DiscoverRoutes } from './discover_router';
|
||||
import { DiscoverMainRoute } from './main';
|
||||
import { SingleDocRoute } from './doc';
|
||||
import { ContextAppRoute } from './context';
|
||||
import { createProfileRegistry } from '../customizations/profile_registry';
|
||||
import { addProfile } from '../../common/customizations';
|
||||
import { NotFoundRoute } from './not_found';
|
||||
|
||||
const pathMap: Record<string, never> = {};
|
||||
let mockProfile: string | undefined;
|
||||
|
||||
describe('Discover router', () => {
|
||||
const props = {
|
||||
isDev: false,
|
||||
jest.mock('react-router-dom', () => {
|
||||
const originalModule = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
...originalModule,
|
||||
useParams: () => ({ profile: mockProfile }),
|
||||
};
|
||||
beforeAll(() => {
|
||||
const { history } = createSearchSessionMock();
|
||||
const component = shallow(discoverRouter(mockDiscoverServices, history, props.isDev));
|
||||
component.find(Route).forEach((route) => {
|
||||
const routeProps = route.props() as RouteProps;
|
||||
const path = routeProps.path;
|
||||
const children = routeProps.children;
|
||||
if (typeof path === 'string') {
|
||||
// @ts-expect-error
|
||||
pathMap[path] = children;
|
||||
}
|
||||
});
|
||||
|
||||
let pathMap: Record<string, never> = {};
|
||||
|
||||
const gatherRoutes = (wrapper: ShallowWrapper) => {
|
||||
wrapper.find(Route).forEach((route) => {
|
||||
const routeProps = route.props() as RouteProps;
|
||||
const path = routeProps.path;
|
||||
const children = routeProps.children;
|
||||
if (typeof path === 'string') {
|
||||
// @ts-expect-error
|
||||
pathMap[path] = children ?? routeProps.render;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const props = {
|
||||
isDev: false,
|
||||
customizationCallbacks: [],
|
||||
};
|
||||
|
||||
describe('DiscoverRoutes', () => {
|
||||
describe('Without prefix', () => {
|
||||
beforeAll(() => {
|
||||
pathMap = {};
|
||||
gatherRoutes(shallow(<DiscoverRoutes {...props} />));
|
||||
});
|
||||
|
||||
it('should show DiscoverMainRoute component for / route', () => {
|
||||
expect(pathMap['/']).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
|
||||
it('should show DiscoverMainRoute component for /view/:id route', () => {
|
||||
expect(pathMap['/view/:id']).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
|
||||
it('should show Redirect component for /doc/:dataView/:index/:type route', () => {
|
||||
const redirectParams = {
|
||||
match: {
|
||||
params: {
|
||||
dataView: '123',
|
||||
index: '456',
|
||||
},
|
||||
},
|
||||
};
|
||||
const redirect = pathMap['/doc/:dataView/:index/:type'] as Function;
|
||||
expect(typeof redirect).toBe('function');
|
||||
expect(redirect(redirectParams)).toMatchObject(<Redirect to="/doc/123/456" />);
|
||||
});
|
||||
|
||||
it('should show SingleDocRoute component for /doc/:dataViewId/:index route', () => {
|
||||
expect(pathMap['/doc/:dataViewId/:index']).toMatchObject(<SingleDocRoute />);
|
||||
});
|
||||
|
||||
it('should show ContextAppRoute component for /context/:dataViewId/:id route', () => {
|
||||
expect(pathMap['/context/:dataViewId/:id']).toMatchObject(<ContextAppRoute />);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show DiscoverMainRoute component for / route', () => {
|
||||
expect(pathMap['/']).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
const prefix = addProfile('', 'test');
|
||||
|
||||
it('should show DiscoverMainRoute component for /view/:id route', () => {
|
||||
expect(pathMap['/view/:id']).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
describe('With prefix', () => {
|
||||
beforeAll(() => {
|
||||
pathMap = {};
|
||||
gatherRoutes(shallow(<DiscoverRoutes prefix={prefix} {...props} />));
|
||||
});
|
||||
|
||||
it('should show SingleDocRoute component for /doc/:dataViewId/:index route', () => {
|
||||
expect(pathMap['/doc/:dataViewId/:index']).toMatchObject(<SingleDocRoute />);
|
||||
});
|
||||
it(`should show DiscoverMainRoute component for ${prefix} route`, () => {
|
||||
expect(pathMap[`${prefix}/`]).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
|
||||
it('should show ContextAppRoute component for /context/:dataViewId/:id route', () => {
|
||||
expect(pathMap['/context/:dataViewId/:id']).toMatchObject(<ContextAppRoute />);
|
||||
it(`should show DiscoverMainRoute component for ${prefix}/view/:id route`, () => {
|
||||
expect(pathMap[`${prefix}/view/:id`]).toMatchObject(<DiscoverMainRoute {...props} />);
|
||||
});
|
||||
|
||||
it(`should show Redirect component for ${prefix}/doc/:dataView/:index/:type route`, () => {
|
||||
const redirectParams = {
|
||||
match: {
|
||||
params: {
|
||||
dataView: '123',
|
||||
index: '456',
|
||||
},
|
||||
},
|
||||
};
|
||||
const redirect = pathMap[`${prefix}/doc/:dataView/:index/:type`] as Function;
|
||||
expect(typeof redirect).toBe('function');
|
||||
expect(redirect(redirectParams)).toMatchObject(<Redirect to={`${prefix}/doc/123/456`} />);
|
||||
});
|
||||
|
||||
it(`should show SingleDocRoute component for ${prefix}/doc/:dataViewId/:index route`, () => {
|
||||
expect(pathMap[`${prefix}/doc/:dataViewId/:index`]).toMatchObject(<SingleDocRoute />);
|
||||
});
|
||||
|
||||
it(`should show ContextAppRoute component for ${prefix}/context/:dataViewId/:id route`, () => {
|
||||
expect(pathMap[`${prefix}/context/:dataViewId/:id`]).toMatchObject(<ContextAppRoute />);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const profileRegistry = createProfileRegistry();
|
||||
const callbacks = [jest.fn()];
|
||||
|
||||
profileRegistry.set({
|
||||
name: 'default',
|
||||
customizationCallbacks: callbacks,
|
||||
});
|
||||
|
||||
profileRegistry.set({
|
||||
name: 'test',
|
||||
customizationCallbacks: callbacks,
|
||||
});
|
||||
|
||||
describe('CustomDiscoverRoutes', () => {
|
||||
afterEach(() => {
|
||||
mockProfile = undefined;
|
||||
});
|
||||
|
||||
it('should show DiscoverRoutes for a valid profile', () => {
|
||||
mockProfile = 'test';
|
||||
const component = shallow(
|
||||
<CustomDiscoverRoutes profileRegistry={profileRegistry} isDev={props.isDev} />
|
||||
);
|
||||
expect(component.find(DiscoverRoutes).getElement()).toMatchObject(
|
||||
<DiscoverRoutes
|
||||
prefix={addProfile('', mockProfile)}
|
||||
customizationCallbacks={callbacks}
|
||||
isDev={props.isDev}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
it('should show NotFoundRoute for an invalid profile', () => {
|
||||
mockProfile = 'invalid';
|
||||
const component = shallow(
|
||||
<CustomDiscoverRoutes profileRegistry={profileRegistry} isDev={props.isDev} />
|
||||
);
|
||||
expect(component.find(NotFoundRoute).getElement()).toMatchObject(<NotFoundRoute />);
|
||||
});
|
||||
});
|
||||
|
||||
const profilePath = addProfile('', ':profile');
|
||||
|
||||
describe('DiscoverRouter', () => {
|
||||
beforeAll(() => {
|
||||
pathMap = {};
|
||||
const { history } = createSearchSessionMock();
|
||||
const component = shallow(
|
||||
<DiscoverRouter
|
||||
services={mockDiscoverServices}
|
||||
history={history}
|
||||
profileRegistry={profileRegistry}
|
||||
isDev={props.isDev}
|
||||
/>
|
||||
);
|
||||
gatherRoutes(component);
|
||||
});
|
||||
|
||||
it('should show DiscoverRoutes component for / route', () => {
|
||||
expect(pathMap['/']).toMatchObject(
|
||||
<DiscoverRoutes customizationCallbacks={callbacks} isDev={props.isDev} />
|
||||
);
|
||||
});
|
||||
|
||||
it(`should show CustomDiscoverRoutes component for ${profilePath} route`, () => {
|
||||
expect(pathMap[profilePath]).toMatchObject(
|
||||
<CustomDiscoverRoutes profileRegistry={profileRegistry} isDev={props.isDev} />
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Redirect, Router, Switch } from 'react-router-dom';
|
||||
import { Redirect, Router, Switch, useParams } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { Route } from '@kbn/shared-ux-router';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { History } from 'history';
|
||||
import { EuiErrorBoundary } from '@elastic/eui';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
|
@ -19,38 +19,111 @@ import { DiscoverMainRoute } from './main';
|
|||
import { NotFoundRoute } from './not_found';
|
||||
import { DiscoverServices } from '../build_services';
|
||||
import { ViewAlertRoute } from './view_alert';
|
||||
import type { CustomizationCallback } from '../customizations';
|
||||
import type { DiscoverProfileRegistry } from '../customizations/profile_registry';
|
||||
import { addProfile } from '../../common/customizations';
|
||||
|
||||
export const discoverRouter = (services: DiscoverServices, history: History, isDev: boolean) => (
|
||||
<KibanaContextProvider services={services}>
|
||||
<EuiErrorBoundary>
|
||||
<Router history={history} data-test-subj="discover-react-router">
|
||||
<CompatRouter>
|
||||
<Switch>
|
||||
<Route path="/context/:dataViewId/:id">
|
||||
<ContextAppRoute />
|
||||
</Route>
|
||||
<Route
|
||||
path="/doc/:dataView/:index/:type"
|
||||
render={(props) => (
|
||||
<Redirect to={`/doc/${props.match.params.dataView}/${props.match.params.index}`} />
|
||||
)}
|
||||
/>
|
||||
<Route path="/doc/:dataViewId/:index">
|
||||
<SingleDocRoute />
|
||||
</Route>
|
||||
<Route path="/viewAlert/:id">
|
||||
<ViewAlertRoute />
|
||||
</Route>
|
||||
<Route path="/view/:id">
|
||||
<DiscoverMainRoute isDev={isDev} />
|
||||
</Route>
|
||||
<Route path="/" exact>
|
||||
<DiscoverMainRoute isDev={isDev} />
|
||||
</Route>
|
||||
<NotFoundRoute />
|
||||
</Switch>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</EuiErrorBoundary>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
interface DiscoverRoutesProps {
|
||||
prefix?: string;
|
||||
customizationCallbacks: CustomizationCallback[];
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
export const DiscoverRoutes = ({ prefix, ...mainRouteProps }: DiscoverRoutesProps) => {
|
||||
const prefixPath = useCallback(
|
||||
(path: string) => (prefix ? `${prefix}/${path}` : `/${path}`),
|
||||
[prefix]
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={prefixPath('context/:dataViewId/:id')}>
|
||||
<ContextAppRoute />
|
||||
</Route>
|
||||
<Route
|
||||
path={prefixPath('doc/:dataView/:index/:type')}
|
||||
render={(props) => (
|
||||
<Redirect
|
||||
to={prefixPath(`doc/${props.match.params.dataView}/${props.match.params.index}`)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route path={prefixPath('doc/:dataViewId/:index')}>
|
||||
<SingleDocRoute />
|
||||
</Route>
|
||||
<Route path={prefixPath('viewAlert/:id')}>
|
||||
<ViewAlertRoute />
|
||||
</Route>
|
||||
<Route path={prefixPath('view/:id')}>
|
||||
<DiscoverMainRoute {...mainRouteProps} />
|
||||
</Route>
|
||||
<Route path={prefixPath('')} exact>
|
||||
<DiscoverMainRoute {...mainRouteProps} />
|
||||
</Route>
|
||||
<NotFoundRoute />
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomDiscoverRoutesProps {
|
||||
profileRegistry: DiscoverProfileRegistry;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
export const CustomDiscoverRoutes = ({ profileRegistry, ...props }: CustomDiscoverRoutesProps) => {
|
||||
const { profile } = useParams<{ profile: string }>();
|
||||
const customizationCallbacks = useMemo(
|
||||
() => profileRegistry.get(profile)?.customizationCallbacks,
|
||||
[profile, profileRegistry]
|
||||
);
|
||||
|
||||
if (customizationCallbacks) {
|
||||
return (
|
||||
<DiscoverRoutes
|
||||
prefix={addProfile('', profile)}
|
||||
customizationCallbacks={customizationCallbacks}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NotFoundRoute />;
|
||||
};
|
||||
|
||||
export interface DiscoverRouterProps {
|
||||
services: DiscoverServices;
|
||||
profileRegistry: DiscoverProfileRegistry;
|
||||
history: History;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
export const DiscoverRouter = ({
|
||||
services,
|
||||
history,
|
||||
profileRegistry,
|
||||
...routeProps
|
||||
}: DiscoverRouterProps) => {
|
||||
const customizationCallbacks = useMemo(
|
||||
() => profileRegistry.get('default')?.customizationCallbacks ?? [],
|
||||
[profileRegistry]
|
||||
);
|
||||
|
||||
return (
|
||||
<KibanaContextProvider services={services}>
|
||||
<EuiErrorBoundary>
|
||||
<Router history={history} data-test-subj="discover-react-router">
|
||||
<CompatRouter>
|
||||
<Switch>
|
||||
<Route path={addProfile('', ':profile')}>
|
||||
<CustomDiscoverRoutes profileRegistry={profileRegistry} {...routeProps} />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<DiscoverRoutes customizationCallbacks={customizationCallbacks} {...routeProps} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</EuiErrorBoundary>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -48,15 +48,16 @@ export interface DocProps {
|
|||
export function Doc(props: DocProps) {
|
||||
const { dataView } = props;
|
||||
const [reqState, hit] = useEsDocSearch(props);
|
||||
const { locator, chrome, docLinks } = useDiscoverServices();
|
||||
const services = useDiscoverServices();
|
||||
const { locator, chrome, docLinks } = services;
|
||||
const indexExistsLink = docLinks.links.apis.indexExists;
|
||||
|
||||
useEffect(() => {
|
||||
chrome.setBreadcrumbs([
|
||||
...getRootBreadcrumbs(props.referrer),
|
||||
...getRootBreadcrumbs({ breadcrumb: props.referrer, services }),
|
||||
{ text: `${props.index}#${props.id}` },
|
||||
]);
|
||||
}, [chrome, props.referrer, props.index, props.id, dataView, locator]);
|
||||
}, [chrome, props.referrer, props.index, props.id, dataView, locator, services]);
|
||||
|
||||
return (
|
||||
<EuiPage>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { addProfile } from '../../../common/customizations';
|
||||
import { DiscoverSingleDocLocatorDefinition } from './locator';
|
||||
|
||||
const dataViewId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002';
|
||||
|
@ -44,4 +45,17 @@ describe('Discover single doc url generator', () => {
|
|||
`"#/doc/c367b774-a4c2-11ea-bb37-0242ac130002/mock-row-index?id=id%20with%20special%20characters%3A%20%26%3F%23%2B"`
|
||||
);
|
||||
});
|
||||
|
||||
test('can specify profile', async () => {
|
||||
const { locator } = await setup();
|
||||
const { path } = await locator.getLocation({
|
||||
profile: 'test',
|
||||
index: dataViewId,
|
||||
rowId: 'mock-row-id',
|
||||
rowIndex: 'mock-row-index',
|
||||
referrer: 'mock-referrer',
|
||||
});
|
||||
|
||||
expect(path).toBe(`${addProfile('#/', 'test')}doc/${dataViewId}/mock-row-index?id=mock-row-id`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import type { SerializableRecord } from '@kbn/utility-types';
|
||||
import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
|
||||
import { DataViewSpec } from '@kbn/data-views-plugin/public';
|
||||
import { addProfile } from '../../../common/customizations';
|
||||
|
||||
export const DISCOVER_SINGLE_DOC_LOCATOR = 'DISCOVER_SINGLE_DOC_LOCATOR';
|
||||
|
||||
|
@ -17,6 +18,7 @@ export interface DiscoverSingleDocLocatorParams extends SerializableRecord {
|
|||
rowId: string;
|
||||
rowIndex: string;
|
||||
referrer: string; // discover main view url
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
export type DiscoverSingleDocLocator = LocatorPublic<DiscoverSingleDocLocatorParams>;
|
||||
|
@ -45,7 +47,13 @@ export class DiscoverSingleDocLocatorDefinition
|
|||
dataViewId = index;
|
||||
}
|
||||
|
||||
const path = `#/doc/${dataViewId}/${rowIndex}?id=${encodeURIComponent(rowId)}`;
|
||||
let path = '#/';
|
||||
|
||||
if (params.profile) {
|
||||
path = addProfile(path, params.profile);
|
||||
}
|
||||
|
||||
path = `${path}doc/${dataViewId}/${rowIndex}?id=${encodeURIComponent(rowId)}`;
|
||||
|
||||
return {
|
||||
app: 'discover',
|
||||
|
|
|
@ -5,12 +5,22 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public';
|
||||
import { discoverRouter } from './discover_router';
|
||||
import { DiscoverRouter } from './discover_router';
|
||||
import { DiscoverServices } from '../build_services';
|
||||
import type { DiscoverProfileRegistry } from '../customizations/profile_registry';
|
||||
|
||||
export const renderApp = (element: HTMLElement, services: DiscoverServices, isDev: boolean) => {
|
||||
export interface RenderAppProps {
|
||||
element: HTMLElement;
|
||||
services: DiscoverServices;
|
||||
profileRegistry: DiscoverProfileRegistry;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
export const renderApp = ({ element, services, profileRegistry, isDev }: RenderAppProps) => {
|
||||
const { history: getHistory, capabilities, chrome, data, core } = services;
|
||||
|
||||
const history = getHistory();
|
||||
|
@ -26,7 +36,15 @@ export const renderApp = (element: HTMLElement, services: DiscoverServices, isDe
|
|||
});
|
||||
}
|
||||
const unmount = toMountPoint(
|
||||
wrapWithTheme(discoverRouter(services, history, isDev), core.theme.theme$)
|
||||
wrapWithTheme(
|
||||
<DiscoverRouter
|
||||
services={services}
|
||||
profileRegistry={profileRegistry}
|
||||
history={history}
|
||||
isDev={isDev}
|
||||
/>,
|
||||
core.theme.theme$
|
||||
)
|
||||
)(element);
|
||||
|
||||
return () => {
|
||||
|
|
|
@ -84,7 +84,6 @@ const getCommonProps = () => {
|
|||
const savedSearchMock = {} as unknown as SavedSearch;
|
||||
return {
|
||||
inspectorAdapters: { requests: new RequestAdapter() },
|
||||
navigateTo: action('navigate to somewhere nice'),
|
||||
onChangeDataView: action('change the data view'),
|
||||
onUpdateQuery: action('update the query'),
|
||||
resetSavedSearch: action('reset the saved search the query'),
|
||||
|
|
|
@ -57,11 +57,10 @@ const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
|
|||
const TopNavMemoized = React.memo(DiscoverTopNav);
|
||||
|
||||
export interface DiscoverLayoutProps {
|
||||
navigateTo: (url: string) => void;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}
|
||||
|
||||
export function DiscoverLayout({ navigateTo, stateContainer }: DiscoverLayoutProps) {
|
||||
export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
||||
const {
|
||||
trackUiMetric,
|
||||
capabilities,
|
||||
|
@ -265,7 +264,6 @@ export function DiscoverLayout({ navigateTo, stateContainer }: DiscoverLayoutPro
|
|||
<TopNavMemoized
|
||||
onOpenInspector={onOpenInspector}
|
||||
query={query}
|
||||
navigateTo={navigateTo}
|
||||
savedQuery={savedQuery}
|
||||
stateContainer={stateContainer}
|
||||
updateQuery={stateContainer.actions.onUpdateQuery}
|
||||
|
|
|
@ -29,6 +29,8 @@ import { DiscoverMainProvider } from '../../services/discover_state_provider';
|
|||
import * as ExistingFieldsHookApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
|
||||
import { ExistenceFetchStatus } from '@kbn/unified-field-list-plugin/public';
|
||||
import { getDataViewFieldList } from './lib/get_field_list';
|
||||
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
|
||||
import type { SearchBarCustomization } from '../../../../customizations';
|
||||
|
||||
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
|
||||
() => Promise.resolve([])
|
||||
|
@ -42,6 +44,29 @@ jest.mock('../../../../kibana_services', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const mockSearchBarCustomization: SearchBarCustomization = {
|
||||
id: 'search_bar',
|
||||
CustomDataViewPicker: jest.fn(() => <div data-test-subj="custom-data-view-picker" />),
|
||||
};
|
||||
|
||||
let mockUseCustomizations = false;
|
||||
|
||||
jest.mock('../../../../customizations', () => ({
|
||||
...jest.requireActual('../../../../customizations'),
|
||||
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
|
||||
if (!mockUseCustomizations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case 'search_bar':
|
||||
return mockSearchBarCustomization;
|
||||
default:
|
||||
throw new Error(`Unknown customization id: ${id}`);
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
function getStateContainer({ query }: { query?: Query | AggregateQuery }) {
|
||||
const state = getDiscoverStateMock({ isTimeBased: true });
|
||||
state.appState.set({
|
||||
|
@ -151,6 +176,7 @@ describe('discover sidebar', function () {
|
|||
|
||||
beforeEach(async () => {
|
||||
props = getCompProps();
|
||||
mockUseCustomizations = false;
|
||||
});
|
||||
|
||||
it('should hide field list', async function () {
|
||||
|
@ -262,4 +288,12 @@ describe('discover sidebar', function () {
|
|||
const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
|
||||
expect(createDataViewButton.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('search bar customization', () => {
|
||||
it('should render CustomDataViewPicker', async () => {
|
||||
mockUseCustomizations = true;
|
||||
const comp = await mountComponent({ ...props, showDataViewPicker: true });
|
||||
expect(comp.find('[data-test-subj="custom-data-view-picker"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
|
||||
import { getRawRecordType } from '../../utils/get_raw_record_type';
|
||||
import { RecordRawType } from '../../services/discover_data_state_container';
|
||||
import { useDiscoverCustomization } from '../../../../customizations';
|
||||
|
||||
export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
|
||||
/**
|
||||
|
@ -256,6 +257,8 @@ export function DiscoverSidebarComponent({
|
|||
]
|
||||
);
|
||||
|
||||
const searchBarCustomization = useDiscoverCustomization('search_bar');
|
||||
|
||||
if (!selectedDataView) {
|
||||
return null;
|
||||
}
|
||||
|
@ -276,20 +279,23 @@ export function DiscoverSidebarComponent({
|
|||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
{Boolean(showDataViewPicker) && (
|
||||
<DataViewPicker
|
||||
currentDataViewId={selectedDataView.id}
|
||||
onChangeDataView={onChangeDataView}
|
||||
onAddField={editField}
|
||||
onDataViewCreated={createNewDataView}
|
||||
trigger={{
|
||||
label: selectedDataView?.getName() || '',
|
||||
'data-test-subj': 'dataView-switch-link',
|
||||
title: selectedDataView?.getIndexPattern() || '',
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(showDataViewPicker) &&
|
||||
(searchBarCustomization?.CustomDataViewPicker ? (
|
||||
<searchBarCustomization.CustomDataViewPicker />
|
||||
) : (
|
||||
<DataViewPicker
|
||||
currentDataViewId={selectedDataView.id}
|
||||
onChangeDataView={onChangeDataView}
|
||||
onAddField={editField}
|
||||
onDataViewCreated={createNewDataView}
|
||||
trigger={{
|
||||
label: selectedDataView?.getName() || '',
|
||||
'data-test-subj': 'dataView-switch-link',
|
||||
title: selectedDataView?.getIndexPattern() || '',
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<EuiFlexItem>
|
||||
<FieldList
|
||||
isProcessing={isProcessing}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { dataViewMock } from '../../../../__mocks__/data_view';
|
||||
import { DiscoverTopNav, DiscoverTopNavProps } from './discover_topnav';
|
||||
|
@ -16,6 +16,8 @@ import { setHeaderActionMenuMounter } from '../../../../kibana_services';
|
|||
import { discoverServiceMock as mockDiscoverService } from '../../../../__mocks__/services';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverMainProvider } from '../../services/discover_state_provider';
|
||||
import type { SearchBarCustomization, TopNavCustomization } from '../../../../customizations';
|
||||
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
|
||||
|
||||
setHeaderActionMenuMounter(jest.fn());
|
||||
|
||||
|
@ -26,6 +28,35 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const mockTopNavCustomization: TopNavCustomization = {
|
||||
id: 'top_nav',
|
||||
};
|
||||
|
||||
const mockSearchBarCustomization: SearchBarCustomization = {
|
||||
id: 'search_bar',
|
||||
CustomDataViewPicker: jest.fn(() => <div data-test-subj="custom-data-view-picker" />),
|
||||
};
|
||||
|
||||
let mockUseCustomizations = false;
|
||||
|
||||
jest.mock('../../../../customizations', () => ({
|
||||
...jest.requireActual('../../../../customizations'),
|
||||
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
|
||||
if (!mockUseCustomizations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case 'top_nav':
|
||||
return mockTopNavCustomization;
|
||||
case 'search_bar':
|
||||
return mockSearchBarCustomization;
|
||||
default:
|
||||
throw new Error(`Unknown customization id: ${id}`);
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
function getProps(savePermissions = true): DiscoverTopNavProps {
|
||||
mockDiscoverService.capabilities.discover!.save = savePermissions;
|
||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
|
||||
|
@ -33,7 +64,6 @@ function getProps(savePermissions = true): DiscoverTopNavProps {
|
|||
|
||||
return {
|
||||
stateContainer,
|
||||
navigateTo: jest.fn(),
|
||||
query: {} as Query,
|
||||
savedQuery: '',
|
||||
updateQuery: jest.fn(),
|
||||
|
@ -44,6 +74,13 @@ function getProps(savePermissions = true): DiscoverTopNavProps {
|
|||
}
|
||||
|
||||
describe('Discover topnav component', () => {
|
||||
beforeEach(() => {
|
||||
mockTopNavCustomization.defaultMenu = undefined;
|
||||
mockTopNavCustomization.getMenuItems = undefined;
|
||||
mockUseCustomizations = false;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('generated config of TopNavMenu config is correct when discover save permissions are assigned', () => {
|
||||
const props = getProps(true);
|
||||
const component = mountWithIntl(
|
||||
|
@ -67,4 +104,91 @@ describe('Discover topnav component', () => {
|
|||
const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual(['new', 'open', 'share', 'inspect']);
|
||||
});
|
||||
|
||||
describe('top nav customization', () => {
|
||||
it('should call getMenuItems', () => {
|
||||
mockUseCustomizations = true;
|
||||
mockTopNavCustomization.getMenuItems = jest.fn(() => [
|
||||
{
|
||||
data: {
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
testId: 'testButton',
|
||||
run: () => {},
|
||||
},
|
||||
order: 350,
|
||||
},
|
||||
]);
|
||||
const props = getProps();
|
||||
const component = mountWithIntl(
|
||||
<DiscoverMainProvider value={props.stateContainer}>
|
||||
<DiscoverTopNav {...props} />
|
||||
</DiscoverMainProvider>
|
||||
);
|
||||
expect(mockTopNavCustomization.getMenuItems).toHaveBeenCalledTimes(1);
|
||||
const topNavMenu = component.find(TopNavMenu);
|
||||
const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual(['new', 'open', 'share', 'test', 'inspect', 'save']);
|
||||
});
|
||||
|
||||
it('should allow disabling default menu items', () => {
|
||||
mockUseCustomizations = true;
|
||||
mockTopNavCustomization.defaultMenu = {
|
||||
newItem: { disabled: true },
|
||||
openItem: { disabled: true },
|
||||
shareItem: { disabled: true },
|
||||
alertsItem: { disabled: true },
|
||||
inspectItem: { disabled: true },
|
||||
saveItem: { disabled: true },
|
||||
};
|
||||
const props = getProps();
|
||||
const component = mountWithIntl(
|
||||
<DiscoverMainProvider value={props.stateContainer}>
|
||||
<DiscoverTopNav {...props} />
|
||||
</DiscoverMainProvider>
|
||||
);
|
||||
const topNavMenu = component.find(TopNavMenu);
|
||||
const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow reordering default menu items', () => {
|
||||
mockUseCustomizations = true;
|
||||
mockTopNavCustomization.defaultMenu = {
|
||||
newItem: { order: 6 },
|
||||
openItem: { order: 5 },
|
||||
shareItem: { order: 4 },
|
||||
alertsItem: { order: 3 },
|
||||
inspectItem: { order: 2 },
|
||||
saveItem: { order: 1 },
|
||||
};
|
||||
const props = getProps();
|
||||
const component = mountWithIntl(
|
||||
<DiscoverMainProvider value={props.stateContainer}>
|
||||
<DiscoverTopNav {...props} />
|
||||
</DiscoverMainProvider>
|
||||
);
|
||||
const topNavMenu = component.find(TopNavMenu);
|
||||
const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual(['save', 'inspect', 'share', 'open', 'new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search bar customization', () => {
|
||||
it('should render CustomDataViewPicker', () => {
|
||||
mockUseCustomizations = true;
|
||||
const props = getProps();
|
||||
const component = mountWithIntl(
|
||||
<DiscoverMainProvider value={props.stateContainer}>
|
||||
<DiscoverTopNav {...props} />
|
||||
</DiscoverMainProvider>
|
||||
);
|
||||
const topNav = component.find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu);
|
||||
expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined();
|
||||
const dataViewPickerOverride = mountWithIntl(
|
||||
topNav.prop('dataViewPickerOverride') as ReactElement
|
||||
).find(mockSearchBarCustomization.CustomDataViewPicker!);
|
||||
expect(dataViewPickerOverride.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,13 +13,13 @@ import { useSavedSearchInitial } from '../../services/discover_state_provider';
|
|||
import { useInternalStateSelector } from '../../services/discover_internal_state_container';
|
||||
import { ENABLE_SQL } from '../../../../../common';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { DiscoverLayoutProps } from '../layout/discover_layout';
|
||||
import { getTopNavLinks } from './get_top_nav_links';
|
||||
import { getHeaderActionMenuMounter } from '../../../../kibana_services';
|
||||
import { DiscoverStateContainer } from '../../services/discover_state';
|
||||
import { onSaveSearch } from './on_save_search';
|
||||
import { useDiscoverCustomization } from '../../../../customizations';
|
||||
|
||||
export type DiscoverTopNavProps = Pick<DiscoverLayoutProps, 'navigateTo'> & {
|
||||
export interface DiscoverTopNavProps {
|
||||
onOpenInspector: () => void;
|
||||
query?: Query | AggregateQuery;
|
||||
savedQuery?: string;
|
||||
|
@ -31,7 +31,7 @@ export type DiscoverTopNavProps = Pick<DiscoverLayoutProps, 'navigateTo'> & {
|
|||
isPlainRecord: boolean;
|
||||
textBasedLanguageModeErrors?: Error;
|
||||
onFieldEdited: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export const DiscoverTopNav = ({
|
||||
onOpenInspector,
|
||||
|
@ -39,7 +39,6 @@ export const DiscoverTopNav = ({
|
|||
savedQuery,
|
||||
stateContainer,
|
||||
updateQuery,
|
||||
navigateTo,
|
||||
isPlainRecord,
|
||||
textBasedLanguageModeErrors,
|
||||
onFieldEdited,
|
||||
|
@ -111,18 +110,27 @@ export const DiscoverTopNav = ({
|
|||
});
|
||||
}, [dataViewEditor, stateContainer]);
|
||||
|
||||
const topNavCustomization = useDiscoverCustomization('top_nav');
|
||||
const topNavMenu = useMemo(
|
||||
() =>
|
||||
getTopNavLinks({
|
||||
dataView,
|
||||
navigateTo,
|
||||
services,
|
||||
state: stateContainer,
|
||||
onOpenInspector,
|
||||
isPlainRecord,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
}),
|
||||
[dataView, navigateTo, services, stateContainer, onOpenInspector, isPlainRecord, adHocDataViews]
|
||||
[
|
||||
adHocDataViews,
|
||||
dataView,
|
||||
isPlainRecord,
|
||||
onOpenInspector,
|
||||
services,
|
||||
stateContainer,
|
||||
topNavCustomization,
|
||||
]
|
||||
);
|
||||
|
||||
const onEditDataView = async (editedDataView: DataView) => {
|
||||
|
@ -181,15 +189,16 @@ export const DiscoverTopNav = ({
|
|||
onSaveSearch({
|
||||
savedSearch: stateContainer.savedSearchState.getState(),
|
||||
services,
|
||||
navigateTo,
|
||||
state: stateContainer,
|
||||
onClose: onCancel,
|
||||
onSaveCb: onSave,
|
||||
});
|
||||
},
|
||||
[navigateTo, services, stateContainer]
|
||||
[services, stateContainer]
|
||||
);
|
||||
|
||||
const searchBarCustomization = useDiscoverCustomization('search_bar');
|
||||
|
||||
return (
|
||||
<AggregateQueryTopNavMenu
|
||||
appName="discover"
|
||||
|
@ -205,7 +214,14 @@ export const DiscoverTopNav = ({
|
|||
showSaveQuery={!isPlainRecord && Boolean(services.capabilities.discover.saveQuery)}
|
||||
showSearchBar={true}
|
||||
useDefaultBehaviors={true}
|
||||
dataViewPickerComponentProps={dataViewPickerProps}
|
||||
dataViewPickerOverride={
|
||||
searchBarCustomization?.CustomDataViewPicker ? (
|
||||
<searchBarCustomization.CustomDataViewPicker />
|
||||
) : undefined
|
||||
}
|
||||
dataViewPickerComponentProps={
|
||||
searchBarCustomization?.CustomDataViewPicker ? undefined : dataViewPickerProps
|
||||
}
|
||||
displayStyle="detached"
|
||||
textBasedLanguageModeErrors={
|
||||
textBasedLanguageModeErrors ? [textBasedLanguageModeErrors] : undefined
|
||||
|
|
|
@ -24,12 +24,12 @@ const state = {} as unknown as DiscoverStateContainer;
|
|||
test('getTopNavLinks result', () => {
|
||||
const topNavLinks = getTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
navigateTo: jest.fn(),
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isPlainRecord: false,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
});
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -77,12 +77,12 @@ test('getTopNavLinks result', () => {
|
|||
test('getTopNavLinks result for sql mode', () => {
|
||||
const topNavLinks = getTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
navigateTo: jest.fn(),
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isPlainRecord: true,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
});
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
|
||||
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { DiscoverAppLocatorParams } from '../../../../../common';
|
||||
import { showOpenSearchPanel } from './show_open_search_panel';
|
||||
import { getSharingData, showPublicUrlSwitch } from '../../../../utils/get_sharing_data';
|
||||
|
@ -16,26 +17,27 @@ import { DiscoverServices } from '../../../../build_services';
|
|||
import { onSaveSearch } from './on_save_search';
|
||||
import { DiscoverStateContainer } from '../../services/discover_state';
|
||||
import { openAlertsPopover } from './open_alerts_popover';
|
||||
import type { TopNavCustomization } from '../../../../customizations';
|
||||
|
||||
/**
|
||||
* Helper function to build the top nav links
|
||||
*/
|
||||
export const getTopNavLinks = ({
|
||||
dataView,
|
||||
navigateTo,
|
||||
services,
|
||||
state,
|
||||
onOpenInspector,
|
||||
isPlainRecord,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
}: {
|
||||
dataView: DataView;
|
||||
navigateTo: (url: string) => void;
|
||||
services: DiscoverServices;
|
||||
state: DiscoverStateContainer;
|
||||
onOpenInspector: () => void;
|
||||
isPlainRecord: boolean;
|
||||
adHocDataViews: DataView[];
|
||||
topNavCustomization: TopNavCustomization | undefined;
|
||||
}): TopNavMenuData[] => {
|
||||
const alerts = {
|
||||
id: 'alerts',
|
||||
|
@ -66,7 +68,7 @@ export const getTopNavLinks = ({
|
|||
description: i18n.translate('discover.localMenu.newSearchDescription', {
|
||||
defaultMessage: 'New Search',
|
||||
}),
|
||||
run: () => navigateTo('/'),
|
||||
run: () => services.locator.navigate({}),
|
||||
testId: 'discoverNewButton',
|
||||
};
|
||||
|
||||
|
@ -85,7 +87,6 @@ export const getTopNavLinks = ({
|
|||
onSaveSearch({
|
||||
savedSearch: state.savedSearchState.getState(),
|
||||
services,
|
||||
navigateTo,
|
||||
state,
|
||||
onClose: () => {
|
||||
anchorElement?.focus();
|
||||
|
@ -159,11 +160,23 @@ export const getTopNavLinks = ({
|
|||
const shareableUrl = link.href;
|
||||
|
||||
// Share -> Get links -> Saved object
|
||||
const shareableUrlForSavedObject = await locator.getUrl(
|
||||
let shareableUrlForSavedObject = await locator.getUrl(
|
||||
{ savedSearchId: savedSearch.id },
|
||||
{ absolute: true }
|
||||
);
|
||||
|
||||
// UrlPanelContent forces a '_g' parameter in the saved object URL:
|
||||
// https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230
|
||||
// Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent
|
||||
// will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover,
|
||||
// so instead we add an empty object for the '_g' parameter to the URL.
|
||||
shareableUrlForSavedObject = setStateToKbnUrl(
|
||||
'_g',
|
||||
{},
|
||||
undefined,
|
||||
shareableUrlForSavedObject
|
||||
);
|
||||
|
||||
services.share.toggleShareContextMenu({
|
||||
anchorElement,
|
||||
allowEmbed: false,
|
||||
|
@ -205,16 +218,37 @@ export const getTopNavLinks = ({
|
|||
},
|
||||
};
|
||||
|
||||
return [
|
||||
newSearch,
|
||||
openSearch,
|
||||
shareSearch,
|
||||
...(services.triggersActionsUi &&
|
||||
const defaultMenu = topNavCustomization?.defaultMenu;
|
||||
const entries = [...(topNavCustomization?.getMenuItems?.() ?? [])];
|
||||
|
||||
if (!defaultMenu?.newItem?.disabled) {
|
||||
entries.push({ data: newSearch, order: defaultMenu?.newItem?.order ?? 100 });
|
||||
}
|
||||
|
||||
if (!defaultMenu?.openItem?.disabled) {
|
||||
entries.push({ data: openSearch, order: defaultMenu?.openItem?.order ?? 200 });
|
||||
}
|
||||
|
||||
if (!defaultMenu?.shareItem?.disabled) {
|
||||
entries.push({ data: shareSearch, order: defaultMenu?.shareItem?.order ?? 300 });
|
||||
}
|
||||
|
||||
if (
|
||||
services.triggersActionsUi &&
|
||||
services.capabilities.management?.insightsAndAlerting?.triggersActions &&
|
||||
!isPlainRecord
|
||||
? [alerts]
|
||||
: []),
|
||||
inspectSearch,
|
||||
...(services.capabilities.discover.save ? [saveSearch] : []),
|
||||
];
|
||||
!isPlainRecord &&
|
||||
!defaultMenu?.alertsItem?.disabled
|
||||
) {
|
||||
entries.push({ data: alerts, order: defaultMenu?.alertsItem?.order ?? 400 });
|
||||
}
|
||||
|
||||
if (!defaultMenu?.inspectItem?.disabled) {
|
||||
entries.push({ data: inspectSearch, order: defaultMenu?.inspectItem?.order ?? 500 });
|
||||
}
|
||||
|
||||
if (services.capabilities.discover.save && !defaultMenu?.saveItem?.disabled) {
|
||||
entries.push({ data: saveSearch, order: defaultMenu?.saveItem?.order ?? 600 });
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.order - b.order).map((entry) => entry.data);
|
||||
};
|
||||
|
|
|
@ -39,7 +39,6 @@ function getStateContainer({ dataView }: { dataView?: DataView } = {}) {
|
|||
describe('onSaveSearch', () => {
|
||||
it('should call showSaveModal', async () => {
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch: savedSearchMock,
|
||||
services: discoverServiceMock,
|
||||
state: getStateContainer(),
|
||||
|
@ -55,7 +54,6 @@ describe('onSaveSearch', () => {
|
|||
});
|
||||
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch: savedSearchMock,
|
||||
services: discoverServiceMock,
|
||||
state: getStateContainer({ dataView: dataViewMock }),
|
||||
|
@ -64,7 +62,6 @@ describe('onSaveSearch', () => {
|
|||
expect(saveModal?.props.isTimeBased).toBe(false);
|
||||
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch: savedSearchMock,
|
||||
services: discoverServiceMock,
|
||||
state: getStateContainer({ dataView: dataViewWithTimefieldMock }),
|
||||
|
@ -79,7 +76,6 @@ describe('onSaveSearch', () => {
|
|||
saveModal = modal;
|
||||
});
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch: {
|
||||
...savedSearchMock,
|
||||
tags: ['tag1', 'tag2'],
|
||||
|
@ -101,7 +97,6 @@ describe('onSaveSearch', () => {
|
|||
};
|
||||
const state = getStateContainer();
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch,
|
||||
services: discoverServiceMock,
|
||||
state,
|
||||
|
@ -135,7 +130,6 @@ describe('onSaveSearch', () => {
|
|||
};
|
||||
const state = getStateContainer();
|
||||
await onSaveSearch({
|
||||
navigateTo: jest.fn(),
|
||||
savedSearch,
|
||||
services: {
|
||||
...serviceMock,
|
||||
|
|
|
@ -17,14 +17,12 @@ import { DiscoverStateContainer } from '../../services/discover_state';
|
|||
import { DOC_TABLE_LEGACY } from '../../../../../common';
|
||||
|
||||
async function saveDataSource({
|
||||
navigateTo,
|
||||
savedSearch,
|
||||
saveOptions,
|
||||
services,
|
||||
state,
|
||||
navigateOrReloadSavedSearch,
|
||||
}: {
|
||||
navigateTo: (url: string) => void;
|
||||
savedSearch: SavedSearch;
|
||||
saveOptions: SaveSavedSearchOptions;
|
||||
services: DiscoverServices;
|
||||
|
@ -45,7 +43,7 @@ async function saveDataSource({
|
|||
});
|
||||
if (navigateOrReloadSavedSearch) {
|
||||
if (id !== prevSavedSearchId) {
|
||||
navigateTo(`/view/${encodeURIComponent(id)}`);
|
||||
services.locator.navigate({ savedSearchId: id });
|
||||
} else {
|
||||
// Update defaults so that "reload saved query" functions correctly
|
||||
state.actions.undoSavedSearchChanges();
|
||||
|
@ -78,14 +76,12 @@ async function saveDataSource({
|
|||
}
|
||||
|
||||
export async function onSaveSearch({
|
||||
navigateTo,
|
||||
savedSearch,
|
||||
services,
|
||||
state,
|
||||
onClose,
|
||||
onSaveCb,
|
||||
}: {
|
||||
navigateTo: (path: string) => void;
|
||||
savedSearch: SavedSearch;
|
||||
services: DiscoverServices;
|
||||
state: DiscoverStateContainer;
|
||||
|
@ -139,7 +135,6 @@ export async function onSaveSearch({
|
|||
const response = await saveDataSource({
|
||||
saveOptions,
|
||||
services,
|
||||
navigateTo,
|
||||
savedSearch,
|
||||
state,
|
||||
navigateOrReloadSavedSearch,
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { RootDragDropProvider } from '@kbn/dom-drag-drop';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useUrlTracking } from './hooks/use_url_tracking';
|
||||
import { DiscoverStateContainer } from './services/discover_state';
|
||||
import { DiscoverLayout } from './components/layout';
|
||||
|
@ -33,13 +33,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
|
|||
const savedSearch = useSavedSearchInitial();
|
||||
const services = useDiscoverServices();
|
||||
const { chrome, docLinks, data, spaces, history } = services;
|
||||
const usedHistory = useHistory();
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
usedHistory.push(path);
|
||||
},
|
||||
[usedHistory]
|
||||
);
|
||||
|
||||
useUrlTracking(stateContainer.savedSearchState);
|
||||
|
||||
|
@ -70,8 +63,8 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
|
|||
useEffect(() => {
|
||||
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
|
||||
chrome.docTitle.change(`Discover${pageTitleSuffix}`);
|
||||
setBreadcrumbsTitle(savedSearch.title, chrome);
|
||||
}, [savedSearch.id, savedSearch.title, chrome, data]);
|
||||
setBreadcrumbsTitle({ title: savedSearch.title, services });
|
||||
}, [chrome.docTitle, savedSearch.id, savedSearch.title, services]);
|
||||
|
||||
useEffect(() => {
|
||||
addHelpMenuToAppChrome(chrome, docLinks);
|
||||
|
@ -88,7 +81,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
|
|||
|
||||
return (
|
||||
<RootDragDropProvider>
|
||||
<DiscoverLayoutMemoized navigateTo={navigateTo} stateContainer={stateContainer} />
|
||||
<DiscoverLayoutMemoized stateContainer={stateContainer} />
|
||||
</RootDragDropProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,6 +16,21 @@ import { MemoryRouter } from 'react-router-dom';
|
|||
import { DiscoverMainApp } from './discover_main_app';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { scopedHistoryMock } from '@kbn/core/public/mocks';
|
||||
import {
|
||||
createCustomizationService,
|
||||
DiscoverCustomizationService,
|
||||
} from '../../customizations/customization_service';
|
||||
|
||||
let mockCustomizationService: DiscoverCustomizationService | undefined;
|
||||
|
||||
jest.mock('../../customizations', () => {
|
||||
const originalModule = jest.requireActual('../../customizations');
|
||||
return {
|
||||
...originalModule,
|
||||
useDiscoverCustomizationService: () => mockCustomizationService,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./discover_main_app', () => {
|
||||
return {
|
||||
DiscoverMainApp: jest.fn().mockReturnValue(<></>),
|
||||
|
@ -23,7 +38,12 @@ jest.mock('./discover_main_app', () => {
|
|||
});
|
||||
|
||||
setScopedHistory(scopedHistoryMock.create());
|
||||
|
||||
describe('DiscoverMainRoute', () => {
|
||||
beforeEach(() => {
|
||||
mockCustomizationService = createCustomizationService();
|
||||
});
|
||||
|
||||
test('renders the main app when hasESData=true & hasUserDataView=true ', async () => {
|
||||
const component = mountComponent(true, true);
|
||||
|
||||
|
@ -41,6 +61,7 @@ describe('DiscoverMainRoute', () => {
|
|||
expect(findTestSubject(component, 'kbnNoDataPage').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders no data view when hasESData=true & hasUserDataView=false', async () => {
|
||||
const component = mountComponent(true, false);
|
||||
|
||||
|
@ -49,6 +70,7 @@ describe('DiscoverMainRoute', () => {
|
|||
expect(findTestSubject(component, 'noDataViewsPrompt').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// skipped because this is the case that never ever should happen, it happened once and was fixed in
|
||||
// https://github.com/elastic/kibana/pull/137824
|
||||
test.skip('renders no data page when hasESData=false & hasUserDataView=true', async () => {
|
||||
|
@ -59,10 +81,26 @@ describe('DiscoverMainRoute', () => {
|
|||
expect(findTestSubject(component, 'kbnNoDataPage').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders LoadingIndicator while customizations are loading', async () => {
|
||||
mockCustomizationService = undefined;
|
||||
const component = mountComponent(true, true);
|
||||
await waitFor(() => {
|
||||
component.update();
|
||||
expect(component.find(DiscoverMainApp).exists()).toBe(false);
|
||||
});
|
||||
mockCustomizationService = createCustomizationService();
|
||||
await waitFor(() => {
|
||||
component.setProps({}).update();
|
||||
expect(component.find(DiscoverMainApp).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mountComponent = (hasESData = true, hasUserDataView = true) => {
|
||||
const props = {
|
||||
isDev: false,
|
||||
customizationCallbacks: [],
|
||||
};
|
||||
|
||||
return mountWithIntl(
|
||||
|
@ -73,6 +111,7 @@ const mountComponent = (hasESData = true, hasUserDataView = true) => {
|
|||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
function getServicesMock(hasESData = true, hasUserDataView = true) {
|
||||
const dataViewsMock = discoverServiceMock.data.dataViews;
|
||||
dataViewsMock.hasData = {
|
||||
|
|
|
@ -28,6 +28,11 @@ import { useDiscoverServices } from '../../hooks/use_discover_services';
|
|||
import { getScopedHistory, getUrlTracker } from '../../kibana_services';
|
||||
import { useAlertResultsToast } from './hooks/use_alert_results_toast';
|
||||
import { DiscoverMainProvider } from './services/discover_state_provider';
|
||||
import {
|
||||
CustomizationCallback,
|
||||
DiscoverCustomizationProvider,
|
||||
useDiscoverCustomizationService,
|
||||
} from '../../customizations';
|
||||
|
||||
const DiscoverMainAppMemoized = memo(DiscoverMainApp);
|
||||
|
||||
|
@ -35,14 +40,14 @@ interface DiscoverLandingParams {
|
|||
id: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
export interface MainRouteProps {
|
||||
customizationCallbacks: CustomizationCallback[];
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
export function DiscoverMainRoute(props: Props) {
|
||||
export function DiscoverMainRoute({ customizationCallbacks, isDev }: MainRouteProps) {
|
||||
const history = useHistory();
|
||||
const services = useDiscoverServices();
|
||||
const { isDev } = props;
|
||||
const {
|
||||
core,
|
||||
chrome,
|
||||
|
@ -58,6 +63,10 @@ export function DiscoverMainRoute(props: Props) {
|
|||
services,
|
||||
})
|
||||
);
|
||||
const customizationService = useDiscoverCustomizationService({
|
||||
customizationCallbacks,
|
||||
stateContainer,
|
||||
});
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasESData, setHasESData] = useState(false);
|
||||
|
@ -145,8 +154,8 @@ export function DiscoverMainRoute(props: Props) {
|
|||
|
||||
chrome.setBreadcrumbs(
|
||||
currentSavedSearch && currentSavedSearch.title
|
||||
? getSavedSearchBreadcrumbs(currentSavedSearch.title)
|
||||
: getRootBreadcrumbs()
|
||||
? getSavedSearchBreadcrumbs({ id: currentSavedSearch.title, services })
|
||||
: getRootBreadcrumbs({ services })
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
@ -180,6 +189,7 @@ export function DiscoverMainRoute(props: Props) {
|
|||
savedSearchId,
|
||||
historyLocationState?.dataViewSpec,
|
||||
chrome,
|
||||
services,
|
||||
history,
|
||||
core.application.navigateToApp,
|
||||
core.theme,
|
||||
|
@ -245,13 +255,15 @@ export function DiscoverMainRoute(props: Props) {
|
|||
return <DiscoverError error={error} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (loading || !customizationService) {
|
||||
return <LoadingIndicator type={hasCustomBranding ? 'spinner' : 'elastic'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscoverMainProvider value={stateContainer}>
|
||||
<DiscoverMainAppMemoized stateContainer={stateContainer} />
|
||||
</DiscoverMainProvider>
|
||||
<DiscoverCustomizationProvider value={customizationService}>
|
||||
<DiscoverMainProvider value={stateContainer}>
|
||||
<DiscoverMainAppMemoized stateContainer={stateContainer} />
|
||||
</DiscoverMainProvider>
|
||||
</DiscoverCustomizationProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
savedSearchMockWithTimeFieldNew,
|
||||
} from '../../../__mocks__/saved_search';
|
||||
import { SavedSearch } from '@kbn/saved-search-plugin/public';
|
||||
import { addProfile } from '../../../../common/customizations';
|
||||
|
||||
function prepareTest(savedSearch: SavedSearch, path: string) {
|
||||
const { history } = createSearchSessionMock();
|
||||
|
@ -39,8 +40,12 @@ describe('test useUrl when the url is changed to /', () => {
|
|||
const { load } = prepareTest(savedSearchMockWithTimeField, '/');
|
||||
expect(load).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
test('loadSavedSearch is triggered when a new saved search is pre-selected ', () => {
|
||||
test('loadSavedSearch is triggered when a new saved search is pre-selected', () => {
|
||||
const { load } = prepareTest(savedSearchMockWithTimeFieldNew, '/');
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('loadSavedSearch is triggered when a new saved search is pre-selected with an active profile', () => {
|
||||
const { load } = prepareTest(savedSearchMockWithTimeFieldNew, addProfile('', 'test'));
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,8 +5,11 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { History } from 'history';
|
||||
import { getProfile } from '../../../../common/customizations';
|
||||
|
||||
export function useUrl({
|
||||
history,
|
||||
savedSearchId,
|
||||
|
@ -24,7 +27,9 @@ export function useUrl({
|
|||
// which could be set through pressing "New" button in top nav or go to "Discover" plugin from the sidebar
|
||||
// to reload the page in a right way
|
||||
const unlistenHistoryBasePath = history.listen(({ pathname, search, hash }) => {
|
||||
if (!search && !hash && pathname === '/' && !savedSearchId) {
|
||||
const { isProfileRootPath } = getProfile(pathname);
|
||||
|
||||
if ((pathname === '/' || isProfileRootPath) && !search && !hash && !savedSearchId) {
|
||||
onNewUrl();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -287,7 +287,9 @@ export function getDiscoverStateContainer({
|
|||
await undoSavedSearchChanges();
|
||||
} else {
|
||||
addLog('[discoverState] onOpenSavedSearch open view URL');
|
||||
history.push(`/view/${encodeURIComponent(newSavedSearchId)}`);
|
||||
services.locator.navigate({
|
||||
savedSearchId: newSavedSearchId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -13,8 +13,6 @@ import { useDiscoverServices } from '../../hooks/use_discover_services';
|
|||
import { displayPossibleDocsDiffInfoAlert } from '../main/hooks/use_alert_results_toast';
|
||||
import { getAlertUtils, QueryParams } from './view_alert_utils';
|
||||
|
||||
const DISCOVER_MAIN_ROUTE = '/';
|
||||
|
||||
type NonNullableEntry<T> = { [K in keyof T]: NonNullable<T[keyof T]> };
|
||||
|
||||
const isActualAlert = (queryParams: QueryParams): queryParams is NonNullableEntry<QueryParams> => {
|
||||
|
@ -58,7 +56,7 @@ export function ViewAlertRoute() {
|
|||
locator.navigate(state);
|
||||
};
|
||||
|
||||
const navigateToDiscoverRoot = () => history.push(DISCOVER_MAIN_ROUTE);
|
||||
const navigateToDiscoverRoot = () => locator.navigate({});
|
||||
|
||||
fetchAlert(id)
|
||||
.then(fetchSearchSource)
|
||||
|
|
|
@ -10,13 +10,13 @@ import React from 'react';
|
|||
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDiscoverServices } from '../../hooks/use_discover_services';
|
||||
|
||||
export const DiscoverError = ({ error }: { error: Error }) => {
|
||||
const history = useHistory();
|
||||
const { locator } = useDiscoverServices();
|
||||
|
||||
const goToMain = () => {
|
||||
history.push('/');
|
||||
locator.navigate({});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { getDiscoverStateMock } from '../__mocks__/discover_state.mock';
|
||||
import {
|
||||
DiscoverCustomizationProvider,
|
||||
useDiscoverCustomization,
|
||||
useDiscoverCustomization$,
|
||||
useDiscoverCustomizationService,
|
||||
} from './customization_provider';
|
||||
import {
|
||||
createCustomizationService,
|
||||
DiscoverCustomization,
|
||||
DiscoverCustomizationId,
|
||||
DiscoverCustomizationService,
|
||||
} from './customization_service';
|
||||
|
||||
describe('useDiscoverCustomizationService', () => {
|
||||
it('should provide customization service', async () => {
|
||||
let resolveCallback = (_: () => void) => {};
|
||||
const promise = new Promise<() => void>((resolve) => {
|
||||
resolveCallback = resolve;
|
||||
});
|
||||
let service: DiscoverCustomizationService | undefined;
|
||||
const callback = jest.fn(({ customizations }) => {
|
||||
service = customizations;
|
||||
return promise;
|
||||
});
|
||||
const wrapper = renderHook(() =>
|
||||
useDiscoverCustomizationService({
|
||||
stateContainer: getDiscoverStateMock({ isTimeBased: true }),
|
||||
customizationCallbacks: [callback],
|
||||
})
|
||||
);
|
||||
expect(wrapper.result.current).toBeUndefined();
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
const cleanup = jest.fn();
|
||||
await act(async () => {
|
||||
resolveCallback(cleanup);
|
||||
await promise;
|
||||
});
|
||||
expect(wrapper.result.current).toBeDefined();
|
||||
expect(wrapper.result.current).toBe(service);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
await act(async () => {
|
||||
await promise;
|
||||
});
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDiscoverCustomization', () => {
|
||||
it('should provide customization', () => {
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
const wrapper = renderHook(() => useDiscoverCustomization('top_nav'), {
|
||||
wrapper: ({ children }) => {
|
||||
const service = createCustomizationService();
|
||||
service.set(customization);
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(wrapper.result.current).toBe(customization);
|
||||
});
|
||||
|
||||
it('should allow changing the customization', () => {
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
const service = createCustomizationService();
|
||||
const wrapper = renderHook((id) => useDiscoverCustomization(id), {
|
||||
initialProps: customization.id as DiscoverCustomizationId,
|
||||
wrapper: ({ children }) => {
|
||||
service.set(customization);
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(wrapper.result.current).toBe(customization);
|
||||
const newCustomization: DiscoverCustomization = { id: 'search_bar' };
|
||||
service.set(newCustomization);
|
||||
wrapper.rerender('search_bar');
|
||||
expect(wrapper.result.current).toBe(newCustomization);
|
||||
});
|
||||
|
||||
it('should provide undefined if customization is not found', () => {
|
||||
const wrapper = renderHook(() => useDiscoverCustomization('top_nav'), {
|
||||
wrapper: ({ children }) => {
|
||||
const service = createCustomizationService();
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(wrapper.result.current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDiscoverCustomization$', () => {
|
||||
it('should provide customization$', () => {
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
const wrapper = renderHook(() => useDiscoverCustomization$('top_nav'), {
|
||||
wrapper: ({ children }) => {
|
||||
const service = createCustomizationService();
|
||||
service.set(customization);
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
let result: DiscoverCustomization | undefined;
|
||||
wrapper.result.current.subscribe((current) => {
|
||||
result = current;
|
||||
});
|
||||
expect(result).toBe(customization);
|
||||
});
|
||||
|
||||
it('should allow changing the customization', () => {
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
const service = createCustomizationService();
|
||||
const wrapper = renderHook((id) => useDiscoverCustomization$(id), {
|
||||
initialProps: customization.id as DiscoverCustomizationId,
|
||||
wrapper: ({ children }) => {
|
||||
service.set(customization);
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
let result: DiscoverCustomization | undefined;
|
||||
wrapper.result.current.subscribe((current) => {
|
||||
result = current;
|
||||
});
|
||||
expect(result).toBe(customization);
|
||||
const newCustomization: DiscoverCustomization = { id: 'search_bar' };
|
||||
service.set(newCustomization);
|
||||
wrapper.rerender('search_bar');
|
||||
wrapper.result.current.subscribe((current) => {
|
||||
result = current;
|
||||
});
|
||||
expect(result).toBe(newCustomization);
|
||||
});
|
||||
|
||||
it('should provide undefined if customization is not found', () => {
|
||||
const wrapper = renderHook(() => useDiscoverCustomization$('top_nav'), {
|
||||
wrapper: ({ children }) => {
|
||||
const service = createCustomizationService();
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={service}>{children}</DiscoverCustomizationProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
let result: DiscoverCustomization | undefined;
|
||||
wrapper.result.current.subscribe((current) => {
|
||||
result = current;
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { isFunction } from 'lodash';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import type { DiscoverStateContainer } from '../application/main/services/discover_state';
|
||||
import type { CustomizationCallback } from './types';
|
||||
import {
|
||||
createCustomizationService,
|
||||
DiscoverCustomizationId,
|
||||
DiscoverCustomizationService,
|
||||
} from './customization_service';
|
||||
|
||||
const customizationContext = createContext(createCustomizationService());
|
||||
|
||||
export const DiscoverCustomizationProvider = customizationContext.Provider;
|
||||
|
||||
export const useDiscoverCustomizationService = ({
|
||||
customizationCallbacks,
|
||||
stateContainer,
|
||||
}: {
|
||||
customizationCallbacks: CustomizationCallback[];
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}) => {
|
||||
const [customizationService, setCustomizationService] = useState<DiscoverCustomizationService>();
|
||||
|
||||
useEffectOnce(() => {
|
||||
const customizations = createCustomizationService();
|
||||
const callbacks = customizationCallbacks.map((callback) =>
|
||||
Promise.resolve(callback({ customizations, stateContainer }))
|
||||
);
|
||||
const initialize = () => Promise.all(callbacks).then((result) => result.filter(isFunction));
|
||||
|
||||
initialize().then(() => {
|
||||
setCustomizationService(customizations);
|
||||
});
|
||||
|
||||
return () => {
|
||||
initialize().then((cleanups) => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return customizationService;
|
||||
};
|
||||
|
||||
export const useDiscoverCustomization$ = <TCustomizationId extends DiscoverCustomizationId>(
|
||||
id: TCustomizationId
|
||||
) => useContext(customizationContext).get$(id);
|
||||
|
||||
export const useDiscoverCustomization = <TCustomizationId extends DiscoverCustomizationId>(
|
||||
id: TCustomizationId
|
||||
) => useObservable(useDiscoverCustomization$(id));
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { createCustomizationService, DiscoverCustomization } from './customization_service';
|
||||
|
||||
describe('createCustomizatonService', () => {
|
||||
it('should return a service', () => {
|
||||
const service = createCustomizationService();
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should add a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(undefined);
|
||||
service.set(customization);
|
||||
expect(current).toBe(customization);
|
||||
});
|
||||
|
||||
it('should update a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = {
|
||||
id: 'top_nav',
|
||||
defaultMenu: { newItem: { disabled: true } },
|
||||
};
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
const updatedCustomization: DiscoverCustomization = {
|
||||
...customization,
|
||||
defaultMenu: { newItem: { disabled: false } },
|
||||
};
|
||||
service.set(updatedCustomization);
|
||||
expect(current).toBe(updatedCustomization);
|
||||
});
|
||||
|
||||
it('should remain disabled when updating a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = {
|
||||
id: 'top_nav',
|
||||
defaultMenu: { newItem: { disabled: true } },
|
||||
};
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
service.disable('top_nav');
|
||||
expect(current).toBeUndefined();
|
||||
const updatedCustomization: DiscoverCustomization = {
|
||||
...customization,
|
||||
defaultMenu: { newItem: { disabled: false } },
|
||||
};
|
||||
service.set(updatedCustomization);
|
||||
expect(current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get$', () => {
|
||||
it('should return a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
});
|
||||
|
||||
it('should return undefined if customization is disabled', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
service.disable('top_nav');
|
||||
expect(current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if customization does not exist', async () => {
|
||||
const service = createCustomizationService();
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
it('should disable a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
service.disable('top_nav');
|
||||
expect(current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not throw if customization does not exist', async () => {
|
||||
const service = createCustomizationService();
|
||||
service.disable('top_nav');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable', () => {
|
||||
it('should enable a customization', async () => {
|
||||
const service = createCustomizationService();
|
||||
const customization: DiscoverCustomization = { id: 'top_nav' };
|
||||
service.set(customization);
|
||||
let current: DiscoverCustomization | undefined;
|
||||
service.get$('top_nav').subscribe((value) => {
|
||||
current = value;
|
||||
});
|
||||
expect(current).toBe(customization);
|
||||
service.disable('top_nav');
|
||||
expect(current).toBeUndefined();
|
||||
service.enable('top_nav');
|
||||
expect(current).toBe(customization);
|
||||
});
|
||||
|
||||
it('should not throw if customization does not exist', async () => {
|
||||
const service = createCustomizationService();
|
||||
service.enable('top_nav');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { filter, map, Observable, startWith, Subject } from 'rxjs';
|
||||
import type { SearchBarCustomization, TopNavCustomization } from './customization_types';
|
||||
|
||||
export type DiscoverCustomization = SearchBarCustomization | TopNavCustomization;
|
||||
|
||||
export type DiscoverCustomizationId = DiscoverCustomization['id'];
|
||||
|
||||
export interface DiscoverCustomizationService {
|
||||
set: (customization: DiscoverCustomization) => void;
|
||||
get$: <TCustomizationId extends DiscoverCustomizationId>(
|
||||
id: TCustomizationId
|
||||
) => Observable<Extract<DiscoverCustomization, { id: TCustomizationId }> | undefined>;
|
||||
enable: (id: DiscoverCustomizationId) => void;
|
||||
disable: (id: DiscoverCustomizationId) => void;
|
||||
}
|
||||
|
||||
interface CustomizationEntry {
|
||||
customization: DiscoverCustomization;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const createCustomizationService = (): DiscoverCustomizationService => {
|
||||
const update$ = new Subject<DiscoverCustomizationId>();
|
||||
const customizations = new Map<DiscoverCustomizationId, CustomizationEntry>();
|
||||
|
||||
return {
|
||||
set: (customization: DiscoverCustomization) => {
|
||||
const entry = customizations.get(customization.id);
|
||||
customizations.set(customization.id, {
|
||||
customization,
|
||||
enabled: entry?.enabled ?? true,
|
||||
});
|
||||
update$.next(customization.id);
|
||||
},
|
||||
|
||||
get$: <TCustomizationId extends DiscoverCustomizationId>(id: TCustomizationId) => {
|
||||
return update$.pipe(
|
||||
startWith(id),
|
||||
filter((currentId) => currentId === id),
|
||||
map(() => {
|
||||
const entry = customizations.get(id);
|
||||
if (entry && entry.enabled) {
|
||||
return entry.customization as Extract<DiscoverCustomization, { id: TCustomizationId }>;
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
enable: (id: DiscoverCustomizationId) => {
|
||||
const entry = customizations.get(id);
|
||||
if (entry && !entry.enabled) {
|
||||
entry.enabled = true;
|
||||
update$.next(entry.customization.id);
|
||||
}
|
||||
},
|
||||
|
||||
disable: (id: DiscoverCustomizationId) => {
|
||||
const entry = customizations.get(id);
|
||||
if (entry && entry.enabled) {
|
||||
entry.enabled = false;
|
||||
update$.next(entry.customization.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './search_bar_customization';
|
||||
export * from './top_nav_customization';
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export interface SearchBarCustomization {
|
||||
id: 'search_bar';
|
||||
CustomDataViewPicker?: ComponentType;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
|
||||
|
||||
export interface TopNavDefaultMenuItem {
|
||||
disabled?: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface TopNavDefaultMenu {
|
||||
newItem?: TopNavDefaultMenuItem;
|
||||
openItem?: TopNavDefaultMenuItem;
|
||||
shareItem?: TopNavDefaultMenuItem;
|
||||
alertsItem?: TopNavDefaultMenuItem;
|
||||
inspectItem?: TopNavDefaultMenuItem;
|
||||
saveItem?: TopNavDefaultMenuItem;
|
||||
}
|
||||
|
||||
export interface TopNavMenuItem {
|
||||
data: TopNavMenuData;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TopNavCustomization {
|
||||
id: 'top_nav';
|
||||
defaultMenu?: TopNavDefaultMenu;
|
||||
getMenuItems?: () => TopNavMenuItem[];
|
||||
}
|
11
src/plugins/discover/public/customizations/index.ts
Normal file
11
src/plugins/discover/public/customizations/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './customization_types';
|
||||
export * from './customization_provider';
|
||||
export * from './types';
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { addProfile } from '../../common/customizations';
|
||||
import { ProfileAwareLocator } from './profile_aware_locator';
|
||||
|
||||
let mockPathname: string | undefined;
|
||||
|
||||
jest.mock('../kibana_services', () => {
|
||||
const originalModule = jest.requireActual('../kibana_services');
|
||||
return {
|
||||
...originalModule,
|
||||
getHistory: jest.fn(() => ({
|
||||
location: {
|
||||
pathname: mockPathname,
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ProfileAwareLocator', () => {
|
||||
beforeEach(() => {
|
||||
mockPathname = undefined;
|
||||
});
|
||||
|
||||
it('should inject profile', async () => {
|
||||
mockPathname = addProfile('', 'test');
|
||||
const locator = {
|
||||
id: 'test',
|
||||
migrations: {},
|
||||
getLocation: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getRedirectUrl: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
navigateSync: jest.fn(),
|
||||
useUrl: jest.fn(),
|
||||
telemetry: jest.fn(),
|
||||
inject: jest.fn(),
|
||||
extract: jest.fn(),
|
||||
};
|
||||
const profileAwareLocator = new ProfileAwareLocator(locator);
|
||||
const params = { foo: 'bar' };
|
||||
const injectedParams = { foo: 'bar', profile: 'test' };
|
||||
await profileAwareLocator.getLocation(params);
|
||||
expect(locator.getLocation).toHaveBeenCalledWith(injectedParams);
|
||||
await profileAwareLocator.getUrl(params, { absolute: true });
|
||||
expect(locator.getUrl).toHaveBeenCalledWith(injectedParams, { absolute: true });
|
||||
profileAwareLocator.getRedirectUrl(params, { lzCompress: true });
|
||||
expect(locator.getRedirectUrl).toHaveBeenCalledWith(injectedParams, { lzCompress: true });
|
||||
await profileAwareLocator.navigate(params, { replace: true });
|
||||
expect(locator.navigate).toHaveBeenCalledWith(injectedParams, { replace: true });
|
||||
profileAwareLocator.navigateSync(params, { replace: true });
|
||||
expect(locator.navigateSync).toHaveBeenCalledWith(injectedParams, { replace: true });
|
||||
profileAwareLocator.useUrl(params, { absolute: true }, ['test']);
|
||||
expect(locator.useUrl).toHaveBeenCalledWith(injectedParams, { absolute: true }, ['test']);
|
||||
profileAwareLocator.telemetry(params, { foo: 'bar' });
|
||||
expect(locator.telemetry).toHaveBeenCalledWith(injectedParams, { foo: 'bar' });
|
||||
await profileAwareLocator.inject(params, [{ id: 'test', name: 'test', type: 'test' }]);
|
||||
expect(locator.inject).toHaveBeenCalledWith(injectedParams, [
|
||||
{ id: 'test', name: 'test', type: 'test' },
|
||||
]);
|
||||
profileAwareLocator.extract(params);
|
||||
expect(locator.extract).toHaveBeenCalledWith(injectedParams);
|
||||
});
|
||||
|
||||
it('should not overwrite the provided profile with an injected one', async () => {
|
||||
mockPathname = addProfile('', 'test');
|
||||
const locator = {
|
||||
id: 'test',
|
||||
migrations: {},
|
||||
getLocation: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getRedirectUrl: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
navigateSync: jest.fn(),
|
||||
useUrl: jest.fn(),
|
||||
telemetry: jest.fn(),
|
||||
inject: jest.fn(),
|
||||
extract: jest.fn(),
|
||||
};
|
||||
const profileAwareLocator = new ProfileAwareLocator(locator);
|
||||
const params = { foo: 'bar', profile: 'test2' };
|
||||
await profileAwareLocator.getLocation(params);
|
||||
expect(locator.getLocation).toHaveBeenCalledWith(params);
|
||||
await profileAwareLocator.getUrl(params, { absolute: true });
|
||||
expect(locator.getUrl).toHaveBeenCalledWith(params, { absolute: true });
|
||||
profileAwareLocator.getRedirectUrl(params, { lzCompress: true });
|
||||
expect(locator.getRedirectUrl).toHaveBeenCalledWith(params, { lzCompress: true });
|
||||
await profileAwareLocator.navigate(params, { replace: true });
|
||||
expect(locator.navigate).toHaveBeenCalledWith(params, { replace: true });
|
||||
profileAwareLocator.navigateSync(params, { replace: true });
|
||||
expect(locator.navigateSync).toHaveBeenCalledWith(params, { replace: true });
|
||||
profileAwareLocator.useUrl(params, { absolute: true }, ['test']);
|
||||
expect(locator.useUrl).toHaveBeenCalledWith(params, { absolute: true }, ['test']);
|
||||
profileAwareLocator.telemetry(params, { foo: 'bar' });
|
||||
expect(locator.telemetry).toHaveBeenCalledWith(params, { foo: 'bar' });
|
||||
await profileAwareLocator.inject(params, [{ id: 'test', name: 'test', type: 'test' }]);
|
||||
expect(locator.inject).toHaveBeenCalledWith(params, [
|
||||
{ id: 'test', name: 'test', type: 'test' },
|
||||
]);
|
||||
profileAwareLocator.extract(params);
|
||||
expect(locator.extract).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should not pass a profile if there is no profile in the URL', async () => {
|
||||
const locator = {
|
||||
id: 'test',
|
||||
migrations: {},
|
||||
getLocation: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getRedirectUrl: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
navigateSync: jest.fn(),
|
||||
useUrl: jest.fn(),
|
||||
telemetry: jest.fn(),
|
||||
inject: jest.fn(),
|
||||
extract: jest.fn(),
|
||||
};
|
||||
const profileAwareLocator = new ProfileAwareLocator(locator);
|
||||
const params = { foo: 'bar' };
|
||||
await profileAwareLocator.getLocation(params);
|
||||
expect(locator.getLocation).toHaveBeenCalledWith(params);
|
||||
await profileAwareLocator.getUrl(params, { absolute: true });
|
||||
expect(locator.getUrl).toHaveBeenCalledWith(params, { absolute: true });
|
||||
profileAwareLocator.getRedirectUrl(params, { lzCompress: true });
|
||||
expect(locator.getRedirectUrl).toHaveBeenCalledWith(params, { lzCompress: true });
|
||||
await profileAwareLocator.navigate(params, { replace: true });
|
||||
expect(locator.navigate).toHaveBeenCalledWith(params, { replace: true });
|
||||
profileAwareLocator.navigateSync(params, { replace: true });
|
||||
expect(locator.navigateSync).toHaveBeenCalledWith(params, { replace: true });
|
||||
profileAwareLocator.useUrl(params, { absolute: true }, ['test']);
|
||||
expect(locator.useUrl).toHaveBeenCalledWith(params, { absolute: true }, ['test']);
|
||||
profileAwareLocator.telemetry(params, { foo: 'bar' });
|
||||
expect(locator.telemetry).toHaveBeenCalledWith(params, { foo: 'bar' });
|
||||
await profileAwareLocator.inject(params, [{ id: 'test', name: 'test', type: 'test' }]);
|
||||
expect(locator.inject).toHaveBeenCalledWith(params, [
|
||||
{ id: 'test', name: 'test', type: 'test' },
|
||||
]);
|
||||
profileAwareLocator.extract(params);
|
||||
expect(locator.extract).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
|
||||
import type {
|
||||
MigrateFunctionsObject,
|
||||
GetMigrationFunctionObjectFn,
|
||||
} from '@kbn/kibana-utils-plugin/common';
|
||||
import type {
|
||||
LocatorGetUrlParams,
|
||||
FormatSearchParamsOptions,
|
||||
LocatorNavigationParams,
|
||||
} from '@kbn/share-plugin/common/url_service';
|
||||
import type { LocatorPublic } from '@kbn/share-plugin/public';
|
||||
import type { DependencyList } from 'react';
|
||||
import { getProfile } from '../../common/customizations';
|
||||
import { getHistory } from '../kibana_services';
|
||||
|
||||
export class ProfileAwareLocator<T extends { profile?: string }> implements LocatorPublic<T> {
|
||||
id: string;
|
||||
migrations: MigrateFunctionsObject | GetMigrationFunctionObjectFn;
|
||||
|
||||
constructor(private readonly locator: LocatorPublic<T>) {
|
||||
this.id = locator.id;
|
||||
this.migrations = locator.migrations;
|
||||
}
|
||||
|
||||
private injectProfile(params: T) {
|
||||
if (params.profile) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const history = getHistory();
|
||||
const { profile } = getProfile(history.location.pathname);
|
||||
|
||||
if (profile) {
|
||||
params = { ...params, profile };
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
getLocation(params: T) {
|
||||
return this.locator.getLocation(this.injectProfile(params));
|
||||
}
|
||||
|
||||
getUrl(params: T, getUrlParams?: LocatorGetUrlParams) {
|
||||
return this.locator.getUrl(this.injectProfile(params), getUrlParams);
|
||||
}
|
||||
|
||||
getRedirectUrl(params: T, options?: FormatSearchParamsOptions) {
|
||||
return this.locator.getRedirectUrl(this.injectProfile(params), options);
|
||||
}
|
||||
|
||||
navigate(params: T, navigationParams?: LocatorNavigationParams) {
|
||||
return this.locator.navigate(this.injectProfile(params), navigationParams);
|
||||
}
|
||||
|
||||
navigateSync(params: T, navigationParams?: LocatorNavigationParams) {
|
||||
return this.locator.navigateSync(this.injectProfile(params), navigationParams);
|
||||
}
|
||||
|
||||
useUrl(
|
||||
params: T,
|
||||
getUrlParams?: LocatorGetUrlParams | undefined,
|
||||
deps?: DependencyList | undefined
|
||||
) {
|
||||
return this.locator.useUrl(this.injectProfile(params), getUrlParams, deps);
|
||||
}
|
||||
|
||||
telemetry(state: T, stats: Record<string, unknown>) {
|
||||
return this.locator.telemetry(this.injectProfile(state), stats);
|
||||
}
|
||||
|
||||
inject(state: T, references: SavedObjectReference[]) {
|
||||
return this.locator.inject(this.injectProfile(state), references);
|
||||
}
|
||||
|
||||
extract(state: T) {
|
||||
return this.locator.extract(this.injectProfile(state));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { createCustomizeFunction, createProfileRegistry } from './profile_registry';
|
||||
|
||||
describe('createProfileRegistry', () => {
|
||||
it('should allow registering profiles', () => {
|
||||
const registry = createProfileRegistry();
|
||||
registry.set({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
registry.set({
|
||||
name: 'test2',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
expect(registry.get('test')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
expect(registry.get('test2')).toEqual({
|
||||
name: 'test2',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow overriding profiles', () => {
|
||||
const registry = createProfileRegistry();
|
||||
registry.set({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
expect(registry.get('test')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
const callback = jest.fn();
|
||||
registry.set({
|
||||
name: 'test',
|
||||
customizationCallbacks: [callback],
|
||||
});
|
||||
expect(registry.get('test')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [callback],
|
||||
});
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const registry = createProfileRegistry();
|
||||
registry.set({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
expect(registry.get('tEsT')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomizeFunction', () => {
|
||||
test('should add a customization callback to the registry', () => {
|
||||
const registry = createProfileRegistry();
|
||||
const customize = createCustomizeFunction(registry);
|
||||
const callback = jest.fn();
|
||||
customize('test', callback);
|
||||
expect(registry.get('test')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [callback],
|
||||
});
|
||||
const callback2 = jest.fn();
|
||||
customize('test', callback2);
|
||||
expect(registry.get('test')).toEqual({
|
||||
name: 'test',
|
||||
customizationCallbacks: [callback, callback2],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { CustomizationCallback } from './types';
|
||||
|
||||
export interface DiscoverProfile {
|
||||
name: string;
|
||||
customizationCallbacks: CustomizationCallback[];
|
||||
}
|
||||
|
||||
export interface DiscoverProfileRegistry {
|
||||
get(name: string): DiscoverProfile | undefined;
|
||||
set(profile: DiscoverProfile): void;
|
||||
}
|
||||
|
||||
export const createProfileRegistry = (): DiscoverProfileRegistry => {
|
||||
const profiles = new Map<string, DiscoverProfile>();
|
||||
|
||||
return {
|
||||
get: (name) => profiles.get(name.toLowerCase()),
|
||||
set: (profile) => profiles.set(profile.name.toLowerCase(), profile),
|
||||
};
|
||||
};
|
||||
|
||||
export const createCustomizeFunction =
|
||||
(profileRegistry: DiscoverProfileRegistry) =>
|
||||
(profileName: string, callback: CustomizationCallback) => {
|
||||
const profile = profileRegistry.get(profileName) ?? {
|
||||
name: profileName,
|
||||
customizationCallbacks: [],
|
||||
};
|
||||
profile.customizationCallbacks.push(callback);
|
||||
profileRegistry.set(profile);
|
||||
};
|
19
src/plugins/discover/public/customizations/types.ts
Normal file
19
src/plugins/discover/public/customizations/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DiscoverStateContainer } from '../application/main/services/discover_state';
|
||||
import type { DiscoverCustomizationService } from './customization_service';
|
||||
|
||||
export interface CustomizationCallbackContext {
|
||||
customizations: DiscoverCustomizationService;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}
|
||||
|
||||
export type CustomizationCallback = (
|
||||
options: CustomizationCallbackContext
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext } from '@kbn/core/public';
|
||||
import type { PluginInitializerContext } from '@kbn/core/public';
|
||||
import { DiscoverPlugin } from './plugin';
|
||||
|
||||
export type { DiscoverSetup, DiscoverStart } from './plugin';
|
||||
|
|
|
@ -25,6 +25,7 @@ const createSetupContract = (): Setup => {
|
|||
const createStartContract = (): Start => {
|
||||
const startContract: Start = {
|
||||
locator: sharePluginMock.createLocator(),
|
||||
customize: jest.fn(),
|
||||
};
|
||||
return startContract;
|
||||
};
|
||||
|
|
|
@ -72,6 +72,8 @@ import {
|
|||
DiscoverSingleDocLocatorDefinition,
|
||||
} from './application/doc/locator';
|
||||
import { DiscoverAppLocator, DiscoverAppLocatorDefinition } from '../common';
|
||||
import type { CustomizationCallback } from './customizations';
|
||||
import { createCustomizeFunction, createProfileRegistry } from './customizations/profile_registry';
|
||||
|
||||
const DocViewerLegacyTable = React.lazy(
|
||||
() => import('./services/doc_views/components/doc_viewer_table/legacy')
|
||||
|
@ -156,6 +158,7 @@ export interface DiscoverStart {
|
|||
* ```
|
||||
*/
|
||||
readonly locator: undefined | DiscoverAppLocator;
|
||||
readonly customize: (profileName: string, callback: CustomizationCallback) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -211,6 +214,7 @@ export class DiscoverPlugin
|
|||
private appStateUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
|
||||
private docViewsRegistry: DocViewsRegistry | null = null;
|
||||
private stopUrlTracking: (() => void) | undefined = undefined;
|
||||
private profileRegistry = createProfileRegistry();
|
||||
private locator?: DiscoverAppLocator;
|
||||
private contextLocator?: DiscoverContextAppLocator;
|
||||
private singleDocLocator?: DiscoverSingleDocLocator;
|
||||
|
@ -218,12 +222,14 @@ export class DiscoverPlugin
|
|||
setup(core: CoreSetup<DiscoverStartPlugins, DiscoverStart>, plugins: DiscoverSetupPlugins) {
|
||||
const baseUrl = core.http.basePath.prepend('/app/discover');
|
||||
const isDev = this.initializerContext.env.mode.dev;
|
||||
|
||||
if (plugins.share) {
|
||||
const useHash = core.uiSettings.get('state:storeInSessionStorage');
|
||||
|
||||
// Create locators for external use without profile-awareness
|
||||
this.locator = plugins.share.url.locators.create(
|
||||
new DiscoverAppLocatorDefinition({ useHash, setStateToKbnUrl })
|
||||
);
|
||||
|
||||
this.contextLocator = plugins.share.url.locators.create(
|
||||
new DiscoverContextAppLocatorDefinition({ useHash })
|
||||
);
|
||||
|
@ -319,13 +325,19 @@ export class DiscoverPlugin
|
|||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
|
||||
const { locator, contextLocator, singleDocLocator } = await getProfileAwareLocators({
|
||||
locator: this.locator!,
|
||||
contextLocator: this.contextLocator!,
|
||||
singleDocLocator: this.singleDocLocator!,
|
||||
});
|
||||
|
||||
const services = buildServices(
|
||||
coreStart,
|
||||
discoverStartPlugins,
|
||||
this.initializerContext,
|
||||
this.locator!,
|
||||
this.contextLocator!,
|
||||
this.singleDocLocator!
|
||||
locator,
|
||||
contextLocator,
|
||||
singleDocLocator
|
||||
);
|
||||
|
||||
// make sure the data view list is up to date
|
||||
|
@ -335,7 +347,12 @@ export class DiscoverPlugin
|
|||
// FIXME: Temporarily hide overflow-y in Discover app when Field Stats table is shown
|
||||
// due to EUI bug https://github.com/elastic/eui/pull/5152
|
||||
params.element.classList.add('dscAppWrapper');
|
||||
const unmount = renderApp(params.element, services, isDev);
|
||||
const unmount = renderApp({
|
||||
element: params.element,
|
||||
services,
|
||||
profileRegistry: this.profileRegistry,
|
||||
isDev,
|
||||
});
|
||||
return () => {
|
||||
unlistenParentHistory();
|
||||
unmount();
|
||||
|
@ -396,6 +413,7 @@ export class DiscoverPlugin
|
|||
|
||||
return {
|
||||
locator: this.locator,
|
||||
customize: createCustomizeFunction(this.profileRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -416,13 +434,19 @@ export class DiscoverPlugin
|
|||
|
||||
const getDiscoverServices = async () => {
|
||||
const [coreStart, discoverStartPlugins] = await core.getStartServices();
|
||||
const { locator, contextLocator, singleDocLocator } = await getProfileAwareLocators({
|
||||
locator: this.locator!,
|
||||
contextLocator: this.contextLocator!,
|
||||
singleDocLocator: this.singleDocLocator!,
|
||||
});
|
||||
|
||||
return buildServices(
|
||||
coreStart,
|
||||
discoverStartPlugins,
|
||||
this.initializerContext,
|
||||
this.locator!,
|
||||
this.contextLocator!,
|
||||
this.singleDocLocator!
|
||||
locator,
|
||||
contextLocator,
|
||||
singleDocLocator
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -430,3 +454,24 @@ export class DiscoverPlugin
|
|||
plugins.embeddable.registerEmbeddableFactory(factory.type, factory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profile-aware locators for internal use
|
||||
*/
|
||||
const getProfileAwareLocators = async ({
|
||||
locator,
|
||||
contextLocator,
|
||||
singleDocLocator,
|
||||
}: {
|
||||
locator: DiscoverAppLocator;
|
||||
contextLocator: DiscoverContextAppLocator;
|
||||
singleDocLocator: DiscoverSingleDocLocator;
|
||||
}) => {
|
||||
const { ProfileAwareLocator } = await import('./customizations/profile_aware_locator');
|
||||
|
||||
return {
|
||||
locator: new ProfileAwareLocator(locator),
|
||||
contextLocator: new ProfileAwareLocator(contextLocator),
|
||||
singleDocLocator: new ProfileAwareLocator(singleDocLocator),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,23 +6,43 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ChromeStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { addProfile, getProfile } from '../../common/customizations';
|
||||
import type { DiscoverServices } from '../build_services';
|
||||
|
||||
export function getRootBreadcrumbs(breadcrumb?: string) {
|
||||
const rootPath = '#/';
|
||||
|
||||
const getRootPath = ({ history }: DiscoverServices) => {
|
||||
const { profile } = getProfile(history().location.pathname);
|
||||
return profile ? addProfile(rootPath, profile) : rootPath;
|
||||
};
|
||||
|
||||
export function getRootBreadcrumbs({
|
||||
breadcrumb,
|
||||
services,
|
||||
}: {
|
||||
breadcrumb?: string;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
text: i18n.translate('discover.rootBreadcrumb', {
|
||||
defaultMessage: 'Discover',
|
||||
}),
|
||||
href: breadcrumb || '#/',
|
||||
href: breadcrumb || getRootPath(services),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSavedSearchBreadcrumbs(id: string) {
|
||||
export function getSavedSearchBreadcrumbs({
|
||||
id,
|
||||
services,
|
||||
}: {
|
||||
id: string;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
return [
|
||||
...getRootBreadcrumbs(),
|
||||
...getRootBreadcrumbs({ services }),
|
||||
{
|
||||
text: id,
|
||||
},
|
||||
|
@ -33,21 +53,27 @@ export function getSavedSearchBreadcrumbs(id: string) {
|
|||
* Helper function to set the Discover's breadcrumb
|
||||
* if there's an active savedSearch, its title is appended
|
||||
*/
|
||||
export function setBreadcrumbsTitle(title: string | undefined, chrome: ChromeStart) {
|
||||
export function setBreadcrumbsTitle({
|
||||
title,
|
||||
services,
|
||||
}: {
|
||||
title: string | undefined;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', {
|
||||
defaultMessage: 'Discover',
|
||||
});
|
||||
|
||||
if (title) {
|
||||
chrome.setBreadcrumbs([
|
||||
services.chrome.setBreadcrumbs([
|
||||
{
|
||||
text: discoverBreadcrumbsTitle,
|
||||
href: '#/',
|
||||
href: getRootPath(services),
|
||||
},
|
||||
{ text: title },
|
||||
]);
|
||||
} else {
|
||||
chrome.setBreadcrumbs([
|
||||
services.chrome.setBreadcrumbs([
|
||||
{
|
||||
text: discoverBreadcrumbsTitle,
|
||||
},
|
||||
|
|
|
@ -52,6 +52,7 @@
|
|||
"@kbn/storybook",
|
||||
"@kbn/shared-ux-router",
|
||||
"@kbn/dom-drag-drop",
|
||||
"@kbn/core-saved-objects-api-server",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -345,6 +345,22 @@ describe('QueryBarTopRowTopRow', () => {
|
|||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Should render custom data view picker', () => {
|
||||
const dataViewPickerOverride = <div data-test-subj="dataViewPickerOverride" />;
|
||||
const { getByTestId } = render(
|
||||
wrapQueryBarTopRowInContext({
|
||||
query: kqlQuery,
|
||||
screenTitle: 'Another Screen',
|
||||
isDirty: false,
|
||||
indexPatterns: [stubIndexPattern],
|
||||
timeHistory: mockTimeHistory,
|
||||
dataViewPickerOverride,
|
||||
})
|
||||
);
|
||||
|
||||
expect(getByTestId('dataViewPickerOverride')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharingMetaFields', () => {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import dateMath from '@kbn/datemath';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import type { Filter, TimeRange, Query, AggregateQuery } from '@kbn/es-query';
|
||||
|
@ -100,6 +100,7 @@ const SuperDatePicker = React.memo(
|
|||
// @internal
|
||||
export interface QueryBarTopRowProps<QT extends Query | AggregateQuery = Query> {
|
||||
customSubmitButton?: any;
|
||||
dataViewPickerOverride?: ReactNode;
|
||||
dataTestSubj?: string;
|
||||
dateRangeFrom?: string;
|
||||
dateRangeTo?: string;
|
||||
|
@ -674,7 +675,7 @@ export const QueryBarTopRow = React.memo(
|
|||
justifyContent={shouldShowDatePickerAsBadge() ? 'flexStart' : 'flexEnd'}
|
||||
wrap
|
||||
>
|
||||
{renderDataViewsPicker()}
|
||||
{props.dataViewPickerOverride || renderDataViewsPicker()}
|
||||
<EuiFlexItem
|
||||
grow={!shouldShowDatePickerAsBadge()}
|
||||
style={{ minWidth: shouldShowDatePickerAsBadge() ? 'auto' : 320, maxWidth: '100%' }}
|
||||
|
|
|
@ -241,6 +241,7 @@ export function createSearchBar({
|
|||
iconType={props.iconType}
|
||||
nonKqlMode={props.nonKqlMode}
|
||||
customSubmitButton={props.customSubmitButton}
|
||||
dataViewPickerOverride={props.dataViewPickerOverride}
|
||||
isClearable={props.isClearable}
|
||||
placeholder={props.placeholder}
|
||||
{...overrideDefaultBehaviors(props)}
|
||||
|
|
|
@ -45,6 +45,7 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> {
|
|||
indexPatterns?: DataView[];
|
||||
isLoading?: boolean;
|
||||
customSubmitButton?: React.ReactNode;
|
||||
dataViewPickerOverride?: React.ReactNode;
|
||||
screenTitle?: string;
|
||||
dataTestSubj?: string;
|
||||
// Togglers
|
||||
|
@ -572,6 +573,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
|
|||
customSubmitButton={
|
||||
this.props.customSubmitButton ? this.props.customSubmitButton : undefined
|
||||
}
|
||||
dataViewPickerOverride={this.props.dataViewPickerOverride}
|
||||
showSubmitButton={this.props.showSubmitButton}
|
||||
submitButtonStyle={this.props.submitButtonStyle}
|
||||
dataTestSubj={this.props.dataTestSubj}
|
||||
|
|
|
@ -30,6 +30,7 @@ export default async function ({ readConfigFile }) {
|
|||
require.resolve('./search'),
|
||||
require.resolve('./content_management'),
|
||||
require.resolve('./unified_field_list_examples'),
|
||||
require.resolve('./discover_customization_examples'),
|
||||
],
|
||||
services: {
|
||||
...functionalConfig.get('services'),
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import type { FtrProviderContext } from '../../functional/ftr_provider_context';
|
||||
|
||||
const TEST_START_TIME = 'Sep 19, 2015 @ 06:31:44.000';
|
||||
const TEST_END_TIME = 'Sep 23, 2015 @ 18:31:44.000';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService, getPageObjects }: FtrProviderContext) => {
|
||||
const PageObjects = getPageObjects(['common', 'timePicker', 'header']);
|
||||
const esArchiver = getService('esArchiver');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const browser = getService('browser');
|
||||
const defaultSettings = { defaultIndex: 'logstash-*' };
|
||||
|
||||
describe('Customizations', () => {
|
||||
before(async () => {
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional');
|
||||
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
|
||||
await kibanaServer.uiSettings.replace(defaultSettings);
|
||||
await PageObjects.common.navigateToApp('home');
|
||||
const currentUrl = await browser.getCurrentUrl();
|
||||
const customizationUrl =
|
||||
currentUrl.substring(0, currentUrl.indexOf('/app/home')) +
|
||||
'/app/discoverCustomizationExamples';
|
||||
await browser.get(customizationUrl);
|
||||
await PageObjects.timePicker.setAbsoluteRange(TEST_START_TIME, TEST_END_TIME);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await kibanaServer.uiSettings.unset('defaultIndex');
|
||||
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
});
|
||||
|
||||
it('Top nav', async () => {
|
||||
await testSubjects.existOrFail('customOptionsButton');
|
||||
await testSubjects.existOrFail('shareTopNavButton');
|
||||
await testSubjects.existOrFail('documentExplorerButton');
|
||||
await testSubjects.missingOrFail('discoverNewButton');
|
||||
await testSubjects.missingOrFail('discoverOpenButton');
|
||||
await testSubjects.click('customOptionsButton');
|
||||
await testSubjects.existOrFail('customOptionsPopover');
|
||||
await testSubjects.click('customOptionsButton');
|
||||
await testSubjects.missingOrFail('customOptionsPopover');
|
||||
});
|
||||
|
||||
it('Search bar', async () => {
|
||||
await testSubjects.click('logsViewSelectorButton');
|
||||
await testSubjects.click('logsViewSelectorOption-ASavedSearch');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const { title, description } = await PageObjects.common.getSharedItemTitleAndDescription();
|
||||
const expected = {
|
||||
title: 'A Saved Search',
|
||||
description: 'A Saved Search Description',
|
||||
};
|
||||
expect(title).to.eql(expected.title);
|
||||
expect(description).to.eql(expected.description);
|
||||
});
|
||||
});
|
||||
};
|
16
test/examples/discover_customization_examples/index.ts
Normal file
16
test/examples/discover_customization_examples/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../functional/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Discover customization examples', () => {
|
||||
loadTestFile(require.resolve('./customizations'));
|
||||
});
|
||||
}
|
|
@ -644,6 +644,8 @@
|
|||
"@kbn/dev-utils/*": ["packages/kbn-dev-utils/*"],
|
||||
"@kbn/developer-examples-plugin": ["examples/developer_examples"],
|
||||
"@kbn/developer-examples-plugin/*": ["examples/developer_examples/*"],
|
||||
"@kbn/discover-customization-examples-plugin": ["examples/discover_customization_examples"],
|
||||
"@kbn/discover-customization-examples-plugin/*": ["examples/discover_customization_examples/*"],
|
||||
"@kbn/discover-enhanced-plugin": ["x-pack/plugins/discover_enhanced"],
|
||||
"@kbn/discover-enhanced-plugin/*": ["x-pack/plugins/discover_enhanced/*"],
|
||||
"@kbn/discover-plugin": ["src/plugins/discover"],
|
||||
|
|
|
@ -4132,6 +4132,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/discover-customization-examples-plugin@link:examples/discover_customization_examples":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/discover-plugin@link:src/plugins/discover":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue