mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[OneDiscover] Contextual App Menu Extension Point (#195448)](https://github.com/elastic/kibana/pull/195448) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Julia Rechkunova","email":"julia.rechkunova@elastic.co"},"sourceCommit":{"committedDate":"2024-10-30T12:35:15Z","message":"[OneDiscover] Contextual App Menu Extension Point (#195448)\n\n- Closes https://github.com/elastic/kibana/issues/194269\r\n\r\n## Summary\r\n\r\nThis PR introduces a new extension point `getAppMenu` which allows to:\r\n- add custom App Menu items (as a button or a submenu with more actions)\r\n- extend Alerts menu item with more custom actions\r\n\r\nAdditionally, this PR rearranges the existing Discover menu items. The\r\nprimary actions are rendered as an icon only now.\r\n\r\n\r\n\r\n\r\nThe example usage of the new extension point can be found in\r\ne7964f08e3/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx (L81-L168)
\r\n\r\n### For testing with the example profile\r\n\r\n1. Add `discover.experimental.enabledProfiles: ['example-root-profile',\r\n'example-data-source-profile', 'example-document-profile']` to\r\n`kibana.dev.yml`\r\n2. Run the following in DevTools\r\n```\r\nPOST _aliases\r\n{\r\n \"actions\": [\r\n {\r\n \"add\": {\r\n \"index\": \"kibana_sample_data_logs\",\r\n \"alias\": \"my-example-logs\"\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n3. Create and use Data View with `my-custom-logs` index pattern\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n- [x] This was checked for [cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Davis McPhee <davis.mcphee@elastic.co>\r\nCo-authored-by: Davis McPhee <davismcphee@hotmail.com>","sha":"811a23830bb60b7b56e08060bc9742fd232a5a8e","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:DataDiscovery","backport:prev-minor","Project:OneDiscover"],"title":"[OneDiscover] Contextual App Menu Extension Point","number":195448,"url":"https://github.com/elastic/kibana/pull/195448","mergeCommit":{"message":"[OneDiscover] Contextual App Menu Extension Point (#195448)\n\n- Closes https://github.com/elastic/kibana/issues/194269\r\n\r\n## Summary\r\n\r\nThis PR introduces a new extension point `getAppMenu` which allows to:\r\n- add custom App Menu items (as a button or a submenu with more actions)\r\n- extend Alerts menu item with more custom actions\r\n\r\nAdditionally, this PR rearranges the existing Discover menu items. The\r\nprimary actions are rendered as an icon only now.\r\n\r\n\r\n\r\n\r\nThe example usage of the new extension point can be found in\r\ne7964f08e3/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx (L81-L168)
\r\n\r\n### For testing with the example profile\r\n\r\n1. Add `discover.experimental.enabledProfiles: ['example-root-profile',\r\n'example-data-source-profile', 'example-document-profile']` to\r\n`kibana.dev.yml`\r\n2. Run the following in DevTools\r\n```\r\nPOST _aliases\r\n{\r\n \"actions\": [\r\n {\r\n \"add\": {\r\n \"index\": \"kibana_sample_data_logs\",\r\n \"alias\": \"my-example-logs\"\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n3. Create and use Data View with `my-custom-logs` index pattern\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n- [x] This was checked for [cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Davis McPhee <davis.mcphee@elastic.co>\r\nCo-authored-by: Davis McPhee <davismcphee@hotmail.com>","sha":"811a23830bb60b7b56e08060bc9742fd232a5a8e"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195448","number":195448,"mergeCommit":{"message":"[OneDiscover] Contextual App Menu Extension Point (#195448)\n\n- Closes https://github.com/elastic/kibana/issues/194269\r\n\r\n## Summary\r\n\r\nThis PR introduces a new extension point `getAppMenu` which allows to:\r\n- add custom App Menu items (as a button or a submenu with more actions)\r\n- extend Alerts menu item with more custom actions\r\n\r\nAdditionally, this PR rearranges the existing Discover menu items. The\r\nprimary actions are rendered as an icon only now.\r\n\r\n\r\n\r\n\r\nThe example usage of the new extension point can be found in\r\ne7964f08e3/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx (L81-L168)
\r\n\r\n### For testing with the example profile\r\n\r\n1. Add `discover.experimental.enabledProfiles: ['example-root-profile',\r\n'example-data-source-profile', 'example-document-profile']` to\r\n`kibana.dev.yml`\r\n2. Run the following in DevTools\r\n```\r\nPOST _aliases\r\n{\r\n \"actions\": [\r\n {\r\n \"add\": {\r\n \"index\": \"kibana_sample_data_logs\",\r\n \"alias\": \"my-example-logs\"\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n3. Create and use Data View with `my-custom-logs` index pattern\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n- [x] This was checked for [cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Davis McPhee <davis.mcphee@elastic.co>\r\nCo-authored-by: Davis McPhee <davismcphee@hotmail.com>","sha":"811a23830bb60b7b56e08060bc9742fd232a5a8e"}}]}] BACKPORT--> Co-authored-by: Julia Rechkunova <julia.rechkunova@elastic.co>
This commit is contained in:
parent
5c8784363d
commit
adf6b7dced
52 changed files with 2564 additions and 1062 deletions
|
@ -7,17 +7,9 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiContextMenu,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
EuiWrappingPopover,
|
||||
IconType,
|
||||
} from '@elastic/eui';
|
||||
import { EuiButton, EuiContextMenu, EuiFlexItem, EuiPopover, IconType } from '@elastic/eui';
|
||||
import { CoreSetup, CoreStart, Plugin, SimpleSavedObject } from '@kbn/core/public';
|
||||
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import type {
|
||||
CustomizationCallback,
|
||||
DiscoverSetup,
|
||||
|
@ -102,112 +94,14 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
|
|||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
this.customizationCallback = ({ 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 = (
|
||||
<KibanaRenderContextProvider {...core}>
|
||||
<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>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
|
||||
ReactDOM.render(element, optionsContainer);
|
||||
},
|
||||
},
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'documentExplorer',
|
||||
label: 'Document explorer',
|
||||
iconType: 'discoverApp',
|
||||
testId: 'documentExplorerButton',
|
||||
run: () => {
|
||||
discover.locator?.navigate({});
|
||||
},
|
||||
},
|
||||
order: 300,
|
||||
},
|
||||
],
|
||||
getBadges: () => {
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
badgeText: 'Example badge',
|
||||
color: 'warning',
|
||||
},
|
||||
order: 10,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
"@kbn/i18n-react",
|
||||
"@kbn/react-kibana-context-theme",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/react-kibana-context-render",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ export {
|
|||
getVisibleColumns,
|
||||
canPrependTimeFieldColumn,
|
||||
DiscoverFlyouts,
|
||||
AppMenuRegistry,
|
||||
dismissAllFlyoutsExceptFor,
|
||||
dismissFlyouts,
|
||||
LogLevelBadge,
|
||||
|
|
184
packages/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap
generated
Normal file
184
packages/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap
generated
Normal file
|
@ -0,0 +1,184 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AppMenuRegistry should allow to override actions under submenu 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 3.2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action Custom",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-1",
|
||||
"type": "custom",
|
||||
},
|
||||
],
|
||||
"id": "action-3",
|
||||
"label": "Action 3",
|
||||
"order": 300,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"iconType": "bell",
|
||||
"label": "Action 1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-1",
|
||||
"order": 100,
|
||||
"type": "primary",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`AppMenuRegistry should allow to register custom actions 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action Custom",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-custom",
|
||||
"type": "custom",
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action Custom Submenu 1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-custom-submenu-1",
|
||||
"type": "custom",
|
||||
},
|
||||
],
|
||||
"id": "action-custom-submenu",
|
||||
"label": "Action Custom Submenu",
|
||||
"type": "custom",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"iconType": "heart",
|
||||
"label": "Action 3.1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-1",
|
||||
"order": 100,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 3.2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
],
|
||||
"id": "action-3",
|
||||
"label": "Action 3",
|
||||
"order": 300,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"iconType": "bell",
|
||||
"label": "Action 1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-1",
|
||||
"order": 100,
|
||||
"type": "primary",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`AppMenuRegistry should allow to register custom actions under submenu 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"iconType": "heart",
|
||||
"label": "Action 3.1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-1",
|
||||
"order": 100,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action Custom",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-custom",
|
||||
"order": 101,
|
||||
"type": "custom",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"label": "Action 3.2",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-3-2",
|
||||
"order": 200,
|
||||
"type": "secondary",
|
||||
},
|
||||
],
|
||||
"id": "action-3",
|
||||
"label": "Action 3",
|
||||
"order": 300,
|
||||
"type": "secondary",
|
||||
},
|
||||
Object {
|
||||
"controlProps": Object {
|
||||
"iconType": "bell",
|
||||
"label": "Action 1",
|
||||
"onClick": [MockFunction],
|
||||
},
|
||||
"id": "action-1",
|
||||
"order": 100,
|
||||
"type": "primary",
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMenuRegistry } from './app_menu_registry';
|
||||
import {
|
||||
AppMenuActionSubmenuSecondary,
|
||||
AppMenuActionType,
|
||||
AppMenuSubmenuActionCustom,
|
||||
} from './types';
|
||||
|
||||
describe('AppMenuRegistry', () => {
|
||||
it('should initialize correctly', () => {
|
||||
const appMenuRegistry = initializeAppMenuRegistry();
|
||||
expect(appMenuRegistry.isActionRegistered('action-1')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-2')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-3')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-3-1')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-3-2')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-n')).toBe(false);
|
||||
expect(appMenuRegistry.getSortedItems()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should allow to register custom actions', () => {
|
||||
const appMenuRegistry = initializeAppMenuRegistry();
|
||||
expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false);
|
||||
|
||||
appMenuRegistry.registerCustomAction({
|
||||
id: 'action-custom',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action Custom',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
appMenuRegistry.registerCustomAction({
|
||||
id: 'action-custom-submenu',
|
||||
type: AppMenuActionType.custom,
|
||||
label: 'Action Custom Submenu',
|
||||
actions: [
|
||||
{
|
||||
id: 'action-custom-submenu-1',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action Custom Submenu 1',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true);
|
||||
expect(appMenuRegistry.isActionRegistered('action-custom-submenu')).toBe(true);
|
||||
expect(appMenuRegistry.getSortedItems()).toHaveLength(5);
|
||||
|
||||
appMenuRegistry.registerCustomAction({
|
||||
id: 'action-custom-extra',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action Custom Extra',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
// should limit the number of custom items
|
||||
const items = appMenuRegistry.getSortedItems();
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should allow to register custom actions under submenu', () => {
|
||||
const appMenuRegistry = initializeAppMenuRegistry();
|
||||
expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false);
|
||||
|
||||
let items = appMenuRegistry.getSortedItems();
|
||||
let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary;
|
||||
expect(items).toHaveLength(3);
|
||||
expect(submenuItem.actions).toHaveLength(2);
|
||||
|
||||
appMenuRegistry.registerCustomActionUnderSubmenu('action-3', {
|
||||
id: 'action-custom',
|
||||
type: AppMenuActionType.custom,
|
||||
order: 101,
|
||||
controlProps: {
|
||||
label: 'Action Custom',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true);
|
||||
|
||||
items = appMenuRegistry.getSortedItems();
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
// calling it again should not add a duplicate
|
||||
items = appMenuRegistry.getSortedItems();
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary;
|
||||
expect(submenuItem.actions).toHaveLength(3);
|
||||
expect(items).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should allow to override actions under submenu', () => {
|
||||
const appMenuRegistry = initializeAppMenuRegistry();
|
||||
|
||||
let items = appMenuRegistry.getSortedItems();
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary;
|
||||
const existingSecondaryActionId = submenuItem.actions[0].id;
|
||||
expect(submenuItem.actions).toHaveLength(2);
|
||||
|
||||
expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true);
|
||||
|
||||
const customAction: AppMenuSubmenuActionCustom = {
|
||||
id: existingSecondaryActionId, // using the same id to override the action with a custom one
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action Custom',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
};
|
||||
appMenuRegistry.registerCustomActionUnderSubmenu('action-3', customAction);
|
||||
|
||||
expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true);
|
||||
|
||||
items = appMenuRegistry.getSortedItems();
|
||||
submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary;
|
||||
expect(submenuItem.actions).toHaveLength(2);
|
||||
expect(submenuItem.actions.find((item) => item.id === existingSecondaryActionId)).toBe(
|
||||
customAction
|
||||
);
|
||||
expect(items).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function initializeAppMenuRegistry() {
|
||||
return new AppMenuRegistry([
|
||||
{
|
||||
id: 'action-1',
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: 'Action 1',
|
||||
iconType: 'bell',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-2',
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: 'Action 2',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-3',
|
||||
type: AppMenuActionType.secondary,
|
||||
label: 'Action 3',
|
||||
actions: [
|
||||
{
|
||||
id: 'action-3-1',
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: 'Action 3.1',
|
||||
iconType: 'heart',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-3-2',
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: 'Action 3.2',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import {
|
||||
AppMenuActionBase,
|
||||
AppMenuActionSubmenuBase,
|
||||
AppMenuActionSubmenuCustom,
|
||||
AppMenuSubmenuHorizontalRule,
|
||||
AppMenuActionSubmenuSecondary,
|
||||
AppMenuActionType,
|
||||
AppMenuItem,
|
||||
AppMenuItemCustom,
|
||||
AppMenuItemPrimary,
|
||||
AppMenuItemSecondary,
|
||||
AppMenuSubmenuActionCustom,
|
||||
} from './types';
|
||||
|
||||
export class AppMenuRegistry {
|
||||
static CUSTOM_ITEMS_LIMIT = 2;
|
||||
|
||||
private appMenuItems: AppMenuItem[];
|
||||
/**
|
||||
* As custom actions can be registered under a submenu from both root and data source profiles, we need to keep track of them separately.
|
||||
* Otherwise, it would be less predictable. For example, we would override/reset the actions from the data source profile with the ones from the root profile.
|
||||
* @private
|
||||
*/
|
||||
private customSubmenuItemsBySubmenuId: Map<
|
||||
string,
|
||||
Array<AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule>
|
||||
>;
|
||||
|
||||
constructor(primaryAndSecondaryActions: Array<AppMenuItemPrimary | AppMenuItemSecondary>) {
|
||||
this.appMenuItems = assignOrderToActions(primaryAndSecondaryActions);
|
||||
this.customSubmenuItemsBySubmenuId = new Map();
|
||||
}
|
||||
|
||||
public isActionRegistered(appMenuItemId: string) {
|
||||
return (
|
||||
this.appMenuItems.some((item) => {
|
||||
if (item.id === appMenuItemId) {
|
||||
return true;
|
||||
}
|
||||
if (isAppMenuActionSubmenu(item)) {
|
||||
return item.actions.some((submenuItem) => submenuItem.id === appMenuItemId);
|
||||
}
|
||||
return false;
|
||||
}) ||
|
||||
[...this.customSubmenuItemsBySubmenuId.values()].some((submenuItems) =>
|
||||
submenuItems.some((item) => item.id === appMenuItemId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom action to the app menu. It can be a simple action or a submenu with more actions and horizontal rules.
|
||||
* Note: Only 2 top level custom actions are allowed to be rendered in the app menu. The rest will be ignored.
|
||||
* A custom action can also open a flyout or a modal. For that, return your custom react node from action's `onClick` event and call `onFinishAction` when you're done.
|
||||
* @param appMenuItem
|
||||
*/
|
||||
public registerCustomAction(appMenuItem: AppMenuItemCustom) {
|
||||
this.appMenuItems = [
|
||||
...this.appMenuItems.filter(
|
||||
// prevent duplicates
|
||||
(item) => !(item.id === appMenuItem.id && item.type === AppMenuActionType.custom)
|
||||
),
|
||||
appMenuItem,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom action under a submenu. It can be an action or a horizontal rule.
|
||||
* Any number of submenu actions can be registered and rendered.
|
||||
* You can also extend an existing submenu with more actions. For example, AppMenuActionType.alerts.
|
||||
* `order` property is optional and can be used to control the order of actions in the submenu.
|
||||
* @param submenuId
|
||||
* @param appMenuItem
|
||||
*/
|
||||
public registerCustomActionUnderSubmenu(
|
||||
submenuId: string,
|
||||
appMenuItem: AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule
|
||||
) {
|
||||
this.customSubmenuItemsBySubmenuId.set(submenuId, [
|
||||
...(this.customSubmenuItemsBySubmenuId.get(submenuId) ?? []).filter(
|
||||
// prevent duplicates and allow overrides
|
||||
(item) => item.id !== appMenuItem.id
|
||||
),
|
||||
appMenuItem,
|
||||
]);
|
||||
}
|
||||
|
||||
private getSortedItemsForType(type: AppMenuActionType) {
|
||||
let actions = this.appMenuItems.filter((item) => item.type === type);
|
||||
|
||||
if (type === AppMenuActionType.custom && actions.length > AppMenuRegistry.CUSTOM_ITEMS_LIMIT) {
|
||||
// apply the limitation on how many custom items can be shown
|
||||
actions = actions.slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT);
|
||||
}
|
||||
|
||||
// enrich submenus with custom actions
|
||||
if (type === AppMenuActionType.secondary || type === AppMenuActionType.custom) {
|
||||
[...this.customSubmenuItemsBySubmenuId.entries()].forEach(([submenuId, customActions]) => {
|
||||
actions = actions.map((item) => {
|
||||
if (item.id === submenuId && isAppMenuActionSubmenu(item)) {
|
||||
return extendSubmenuWithCustomActions(item, customActions);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return sortAppMenuItemsByOrder(actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting app menu items sorted by type and order.
|
||||
*/
|
||||
public getSortedItems() {
|
||||
const primaryItems = this.getSortedItemsForType(AppMenuActionType.primary);
|
||||
const secondaryItems = this.getSortedItemsForType(AppMenuActionType.secondary);
|
||||
const customItems = this.getSortedItemsForType(AppMenuActionType.custom);
|
||||
|
||||
return [...customItems, ...secondaryItems, ...primaryItems];
|
||||
}
|
||||
}
|
||||
|
||||
function isAppMenuActionSubmenu(
|
||||
appMenuItem: AppMenuItem
|
||||
): appMenuItem is AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom {
|
||||
return 'actions' in appMenuItem && Array.isArray(appMenuItem.actions);
|
||||
}
|
||||
|
||||
const FALLBACK_ORDER = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
function sortByOrder<T extends AppMenuActionBase>(a: T, b: T): number {
|
||||
return (a.order ?? FALLBACK_ORDER) - (b.order ?? FALLBACK_ORDER);
|
||||
}
|
||||
|
||||
function getAppMenuSubmenuWithSortedItemsByOrder<
|
||||
T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom
|
||||
>(appMenuItem: T): T {
|
||||
return {
|
||||
...appMenuItem,
|
||||
actions: [...appMenuItem.actions].sort(sortByOrder),
|
||||
};
|
||||
}
|
||||
|
||||
function sortAppMenuItemsByOrder(appMenuItems: AppMenuItem[]): AppMenuItem[] {
|
||||
const sortedAppMenuItems = [...appMenuItems].sort(sortByOrder);
|
||||
return sortedAppMenuItems.map((appMenuItem) => {
|
||||
if (isAppMenuActionSubmenu(appMenuItem)) {
|
||||
return getAppMenuSubmenuWithSortedItemsByOrder(appMenuItem);
|
||||
}
|
||||
return appMenuItem;
|
||||
});
|
||||
}
|
||||
|
||||
function getAppMenuSubmenuWithAssignedOrder<
|
||||
T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom
|
||||
>(appMenuItem: T, order: number): T {
|
||||
let orderInSubmenu = 0;
|
||||
const actionsWithOrder = appMenuItem.actions.map((action) => {
|
||||
orderInSubmenu = orderInSubmenu + 100;
|
||||
return {
|
||||
...action,
|
||||
order: action.order ?? orderInSubmenu,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...appMenuItem,
|
||||
order: appMenuItem.order ?? order,
|
||||
actions: actionsWithOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function extendSubmenuWithCustomActions<
|
||||
T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom
|
||||
>(
|
||||
appMenuItem: T,
|
||||
customActions: Array<AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule>
|
||||
): T {
|
||||
const customActionsIds = new Set(customActions.map((action) => action.id));
|
||||
return {
|
||||
...appMenuItem,
|
||||
actions: [
|
||||
...appMenuItem.actions.filter((item) => !customActionsIds.has(item.id)), // allow to override secondary actions with custom ones
|
||||
...customActions,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All primary and secondary actions by default get order 100, 200, 300,... assigned to them.
|
||||
* Same for actions under a submenu.
|
||||
* @param appMenuItems
|
||||
*/
|
||||
function assignOrderToActions(appMenuItems: AppMenuItem[]): AppMenuItem[] {
|
||||
let order = 0;
|
||||
return appMenuItems.map((appMenuItem) => {
|
||||
order = order + 100;
|
||||
if (isAppMenuActionSubmenu(appMenuItem)) {
|
||||
return getAppMenuSubmenuWithAssignedOrder(appMenuItem, order);
|
||||
}
|
||||
return {
|
||||
...appMenuItem,
|
||||
order: appMenuItem.order ?? order,
|
||||
};
|
||||
});
|
||||
}
|
148
packages/kbn-discover-utils/src/components/app_menu/types.ts
Normal file
148
packages/kbn-discover-utils/src/components/app_menu/types.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
|
||||
import type { EuiIconType } from '@elastic/eui/src/components/icon/icon';
|
||||
|
||||
export interface AppMenuControlOnClickParams {
|
||||
anchorElement: HTMLElement;
|
||||
onFinishAction: () => void;
|
||||
}
|
||||
|
||||
export type AppMenuControlProps = Pick<
|
||||
TopNavMenuData,
|
||||
'testId' | 'isLoading' | 'label' | 'description' | 'disableButton' | 'href' | 'tooltip'
|
||||
> & {
|
||||
onClick:
|
||||
| ((params: AppMenuControlOnClickParams) => Promise<React.ReactNode | void>)
|
||||
| ((params: AppMenuControlOnClickParams) => React.ReactNode | void)
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type AppMenuControlWithIconProps = AppMenuControlProps & {
|
||||
iconType: EuiIconType;
|
||||
};
|
||||
|
||||
interface ControlWithOptionalIcon {
|
||||
iconType?: EuiIconType;
|
||||
}
|
||||
|
||||
export enum AppMenuActionId {
|
||||
new = 'new',
|
||||
open = 'open',
|
||||
share = 'share',
|
||||
alerts = 'alerts',
|
||||
inspect = 'inspect',
|
||||
createRule = 'createRule',
|
||||
manageRulesAndConnectors = 'manageRulesAndConnectors',
|
||||
}
|
||||
|
||||
export enum AppMenuActionType {
|
||||
primary = 'primary',
|
||||
secondary = 'secondary',
|
||||
custom = 'custom',
|
||||
submenuHorizontalRule = 'submenuHorizontalRule',
|
||||
}
|
||||
|
||||
export interface AppMenuActionBase {
|
||||
readonly id: AppMenuActionId | string;
|
||||
readonly order?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A secondary menu action
|
||||
*/
|
||||
export interface AppMenuActionSecondary extends AppMenuActionBase {
|
||||
readonly type: AppMenuActionType.secondary;
|
||||
readonly controlProps: AppMenuControlProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* A secondary submenu action
|
||||
*/
|
||||
export interface AppMenuSubmenuActionSecondary
|
||||
extends Omit<AppMenuActionSecondary, 'controlProps'> {
|
||||
readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom menu action
|
||||
*/
|
||||
export interface AppMenuActionCustom extends AppMenuActionBase {
|
||||
readonly type: AppMenuActionType.custom;
|
||||
readonly controlProps: AppMenuControlProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom submenu action
|
||||
*/
|
||||
export interface AppMenuSubmenuActionCustom extends Omit<AppMenuActionCustom, 'controlProps'> {
|
||||
readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* A primary menu action (with icon only)
|
||||
*/
|
||||
export interface AppMenuActionPrimary extends AppMenuActionBase {
|
||||
readonly type: AppMenuActionType.primary;
|
||||
readonly controlProps: AppMenuControlWithIconProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontal rule between menu items
|
||||
*/
|
||||
export interface AppMenuSubmenuHorizontalRule extends AppMenuActionBase {
|
||||
readonly type: AppMenuActionType.submenuHorizontalRule;
|
||||
readonly testId?: TopNavMenuData['testId'];
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu action which opens a submenu with more actions
|
||||
*/
|
||||
export interface AppMenuActionSubmenuBase<T = AppMenuActionSecondary | AppMenuActionCustom>
|
||||
extends AppMenuActionBase {
|
||||
readonly type: T extends AppMenuActionSecondary
|
||||
? AppMenuActionType.secondary
|
||||
: AppMenuActionType.custom;
|
||||
readonly label: TopNavMenuData['label'];
|
||||
readonly description?: TopNavMenuData['description'];
|
||||
readonly testId?: TopNavMenuData['testId'];
|
||||
readonly actions: T extends AppMenuActionSecondary
|
||||
? Array<
|
||||
AppMenuSubmenuActionSecondary | AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule
|
||||
>
|
||||
: Array<AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu action which opens a submenu with more secondary actions
|
||||
*/
|
||||
export type AppMenuActionSubmenuSecondary = AppMenuActionSubmenuBase<AppMenuActionSecondary>;
|
||||
/**
|
||||
* A menu action which opens a submenu with more custom actions
|
||||
*/
|
||||
export type AppMenuActionSubmenuCustom = AppMenuActionSubmenuBase<AppMenuActionCustom>;
|
||||
|
||||
/**
|
||||
* A primary menu item can only have an icon
|
||||
*/
|
||||
export type AppMenuItemPrimary = AppMenuActionPrimary;
|
||||
/**
|
||||
* A secondary menu item can have only a label or a submenu
|
||||
*/
|
||||
export type AppMenuItemSecondary = AppMenuActionSecondary | AppMenuActionSubmenuSecondary;
|
||||
/**
|
||||
* A custom menu item can have only a label or a submenu
|
||||
*/
|
||||
export type AppMenuItemCustom = AppMenuActionCustom | AppMenuActionSubmenuCustom;
|
||||
/**
|
||||
* A menu item can be primary, secondary or custom
|
||||
*/
|
||||
export type AppMenuItem = AppMenuItemPrimary | AppMenuItemSecondary | AppMenuItemCustom;
|
|
@ -14,3 +14,4 @@ export * from './utils';
|
|||
export * from './data_types';
|
||||
|
||||
export * from './components/custom_control_columns';
|
||||
export { AppMenuRegistry } from './components/app_menu/app_menu_registry';
|
||||
|
|
|
@ -17,6 +17,8 @@ export type {
|
|||
RowControlProps,
|
||||
RowControlRowProps,
|
||||
} from './components/custom_control_columns/types';
|
||||
export type * from './components/app_menu/types';
|
||||
export { AppMenuActionId, AppMenuActionType } from './components/app_menu/types';
|
||||
|
||||
type DiscoverSearchHit = SearchHit<Record<string, unknown>>;
|
||||
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
"@kbn/core-ui-settings-browser",
|
||||
"@kbn/expressions-plugin",
|
||||
"@kbn/logs-data-access-plugin",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/i18n-react"
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/navigation-plugin",
|
||||
"@kbn/ui-theme"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import {
|
||||
AppMenuActionPrimary,
|
||||
AppMenuActionSecondary,
|
||||
AppMenuActionSubmenuCustom,
|
||||
AppMenuActionType,
|
||||
} from '@kbn/discover-utils';
|
||||
import { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item';
|
||||
import { discoverServiceMock } from '../../../../../__mocks__/services';
|
||||
|
||||
describe('convertAppMenuItemToTopNavItem', () => {
|
||||
it('should convert a primary AppMenuItem to TopNavMenuData', () => {
|
||||
const appMenuItem: AppMenuActionPrimary = {
|
||||
id: 'action-1',
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: 'Action 1',
|
||||
testId: 'action-1',
|
||||
iconType: 'share',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const topNavItem = convertAppMenuItemToTopNavItem({
|
||||
appMenuItem,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
expect(topNavItem).toEqual({
|
||||
id: 'action-1',
|
||||
label: 'Action 1',
|
||||
description: 'Action 1',
|
||||
testId: 'action-1',
|
||||
run: expect.any(Function),
|
||||
iconType: 'share',
|
||||
iconOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert a secondary AppMenuItem to TopNavMenuData', () => {
|
||||
const appMenuItem: AppMenuActionSecondary = {
|
||||
id: 'action-2',
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: 'Action Secondary',
|
||||
testId: 'action-secondary',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const topNavItem = convertAppMenuItemToTopNavItem({
|
||||
appMenuItem,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
expect(topNavItem).toEqual({
|
||||
id: 'action-2',
|
||||
label: 'Action Secondary',
|
||||
description: 'Action Secondary',
|
||||
testId: 'action-secondary',
|
||||
run: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert a custom AppMenuItem to TopNavMenuData', () => {
|
||||
const appMenuItem: AppMenuActionSubmenuCustom = {
|
||||
id: 'action-3',
|
||||
type: AppMenuActionType.custom,
|
||||
label: 'Action submenu',
|
||||
testId: 'action-submenu',
|
||||
actions: [
|
||||
{
|
||||
id: 'action-3-1',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action 3.1',
|
||||
testId: 'action-3-1',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-3-2',
|
||||
type: AppMenuActionType.submenuHorizontalRule,
|
||||
},
|
||||
{
|
||||
id: 'action-3-3',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action 3.3',
|
||||
testId: 'action-3-3',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const topNavItem = convertAppMenuItemToTopNavItem({
|
||||
appMenuItem,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
expect(topNavItem).toEqual({
|
||||
id: 'action-3',
|
||||
label: 'Action submenu',
|
||||
description: 'Action submenu',
|
||||
testId: 'action-submenu',
|
||||
run: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMenuActionType, AppMenuItem } from '@kbn/discover-utils';
|
||||
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
|
||||
import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action';
|
||||
import { DiscoverServices } from '../../../../../build_services';
|
||||
|
||||
export function convertAppMenuItemToTopNavItem({
|
||||
appMenuItem,
|
||||
services,
|
||||
}: {
|
||||
appMenuItem: AppMenuItem;
|
||||
services: DiscoverServices;
|
||||
}): TopNavMenuData {
|
||||
if ('actions' in appMenuItem) {
|
||||
return {
|
||||
id: appMenuItem.id,
|
||||
label: appMenuItem.label,
|
||||
description: appMenuItem.description ?? appMenuItem.label,
|
||||
testId: appMenuItem.testId,
|
||||
run: (anchorElement: HTMLElement) => {
|
||||
runAppMenuPopoverAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: appMenuItem.id,
|
||||
label: appMenuItem.controlProps.label,
|
||||
description: appMenuItem.controlProps.description ?? appMenuItem.controlProps.label,
|
||||
testId: appMenuItem.controlProps.testId,
|
||||
run: async (anchorElement: HTMLElement) => {
|
||||
await runAppMenuAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services,
|
||||
});
|
||||
},
|
||||
...(appMenuItem.type === AppMenuActionType.primary
|
||||
? { iconType: appMenuItem.controlProps.iconType, iconOnly: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
|
@ -10,28 +10,40 @@
|
|||
import React from 'react';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { AlertsPopover } from './open_alerts_popover';
|
||||
import { discoverServiceMock } from '../../../../__mocks__/services';
|
||||
import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield';
|
||||
import { dataViewWithNoTimefieldMock } from '../../../../__mocks__/data_view_no_timefield';
|
||||
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { AppMenuActionsMenuPopover } from './run_app_menu_action';
|
||||
import { getAlertsAppMenuItem } from './get_alerts';
|
||||
import { discoverServiceMock } from '../../../../../__mocks__/services';
|
||||
import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield';
|
||||
import { dataViewWithNoTimefieldMock } from '../../../../../__mocks__/data_view_no_timefield';
|
||||
import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock';
|
||||
|
||||
const mount = (dataView = dataViewMock, isEsqlMode = false) => {
|
||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
|
||||
stateContainer.actions.setDataView(dataView);
|
||||
|
||||
const discoverParamsMock = {
|
||||
dataView,
|
||||
adHocDataViews: [],
|
||||
isEsqlMode,
|
||||
onNewSearch: jest.fn(),
|
||||
onOpenSavedSearch: jest.fn(),
|
||||
onUpdateAdHocDataViews: jest.fn(),
|
||||
};
|
||||
|
||||
const alertsAppMenuItem = getAlertsAppMenuItem({
|
||||
discoverParams: discoverParamsMock,
|
||||
services: discoverServiceMock,
|
||||
stateContainer,
|
||||
});
|
||||
|
||||
return mountWithIntl(
|
||||
<KibanaContextProvider services={discoverServiceMock}>
|
||||
<AlertsPopover
|
||||
stateContainer={stateContainer}
|
||||
anchorElement={document.createElement('div')}
|
||||
adHocDataViews={[]}
|
||||
isEsqlMode={isEsqlMode}
|
||||
services={discoverServiceMock}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
<AppMenuActionsMenuPopover
|
||||
anchorElement={document.createElement('div')}
|
||||
appMenuItem={alertsAppMenuItem}
|
||||
services={discoverServiceMock}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
AppMenuActionId,
|
||||
AppMenuActionSubmenuSecondary,
|
||||
AppMenuActionType,
|
||||
} from '@kbn/discover-utils';
|
||||
import {
|
||||
AlertConsumers,
|
||||
ES_QUERY_ID,
|
||||
RuleCreationValidConsumer,
|
||||
STACK_ALERTS_FEATURE_ID,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { RuleTypeMetaData } from '@kbn/alerting-plugin/common';
|
||||
import { DiscoverStateContainer } from '../../../state_management/discover_state';
|
||||
import { AppMenuDiscoverParams } from './types';
|
||||
import { DiscoverServices } from '../../../../../build_services';
|
||||
|
||||
const EsQueryValidConsumer: RuleCreationValidConsumer[] = [
|
||||
AlertConsumers.INFRASTRUCTURE,
|
||||
AlertConsumers.LOGS,
|
||||
AlertConsumers.OBSERVABILITY,
|
||||
STACK_ALERTS_FEATURE_ID,
|
||||
];
|
||||
|
||||
interface EsQueryAlertMetaData extends RuleTypeMetaData {
|
||||
isManagementPage?: boolean;
|
||||
adHocDataViewList: DataView[];
|
||||
}
|
||||
|
||||
const CreateAlertFlyout: React.FC<{
|
||||
discoverParams: AppMenuDiscoverParams;
|
||||
services: DiscoverServices;
|
||||
onFinishAction: () => void;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}> = ({ stateContainer, discoverParams, services, onFinishAction }) => {
|
||||
const query = stateContainer.appState.getState().query;
|
||||
|
||||
const { dataView, isEsqlMode, adHocDataViews, onUpdateAdHocDataViews } = discoverParams;
|
||||
const { triggersActionsUi } = services;
|
||||
const timeField = getTimeField(dataView);
|
||||
|
||||
/**
|
||||
* Provides the default parameters used to initialize the new rule
|
||||
*/
|
||||
const getParams = useCallback(() => {
|
||||
if (isEsqlMode) {
|
||||
return {
|
||||
searchType: 'esqlQuery',
|
||||
esqlQuery: query,
|
||||
timeField,
|
||||
};
|
||||
}
|
||||
const savedQueryId = stateContainer.appState.getState().savedQuery;
|
||||
return {
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: stateContainer.savedSearchState
|
||||
.getState()
|
||||
.searchSource.getSerializedFields(),
|
||||
savedQueryId,
|
||||
};
|
||||
}, [isEsqlMode, stateContainer.appState, stateContainer.savedSearchState, query, timeField]);
|
||||
|
||||
const discoverMetadata: EsQueryAlertMetaData = useMemo(
|
||||
() => ({
|
||||
isManagementPage: false,
|
||||
adHocDataViewList: adHocDataViews,
|
||||
}),
|
||||
[adHocDataViews]
|
||||
);
|
||||
|
||||
return triggersActionsUi?.getAddRuleFlyout({
|
||||
metadata: discoverMetadata,
|
||||
consumer: 'alerts',
|
||||
onClose: (_, metadata) => {
|
||||
onUpdateAdHocDataViews(metadata!.adHocDataViewList);
|
||||
onFinishAction();
|
||||
},
|
||||
onSave: async (metadata) => {
|
||||
onUpdateAdHocDataViews(metadata!.adHocDataViewList);
|
||||
},
|
||||
canChangeTrigger: false,
|
||||
ruleTypeId: ES_QUERY_ID,
|
||||
initialValues: { params: getParams() },
|
||||
validConsumers: EsQueryValidConsumer,
|
||||
useRuleProducer: true,
|
||||
// Default to the Logs consumer if it's available. This should fall back to Stack Alerts if it's not.
|
||||
initialSelectedConsumer: AlertConsumers.LOGS,
|
||||
});
|
||||
};
|
||||
|
||||
export const getAlertsAppMenuItem = ({
|
||||
discoverParams,
|
||||
services,
|
||||
stateContainer,
|
||||
}: {
|
||||
discoverParams: AppMenuDiscoverParams;
|
||||
services: DiscoverServices;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}): AppMenuActionSubmenuSecondary => {
|
||||
const { dataView, isEsqlMode } = discoverParams;
|
||||
const timeField = getTimeField(dataView);
|
||||
const hasTimeFieldName = !isEsqlMode ? Boolean(dataView?.timeFieldName) : Boolean(timeField);
|
||||
|
||||
return {
|
||||
id: AppMenuActionId.alerts,
|
||||
type: AppMenuActionType.secondary,
|
||||
label: i18n.translate('discover.localMenu.localMenu.alertsTitle', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.alertsDescription', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
testId: 'discoverAlertsButton',
|
||||
actions: [
|
||||
{
|
||||
id: AppMenuActionId.createRule,
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.alerts.createSearchThreshold', {
|
||||
defaultMessage: 'Create search threshold rule',
|
||||
}),
|
||||
iconType: 'bell',
|
||||
testId: 'discoverCreateAlertButton',
|
||||
disableButton: !hasTimeFieldName,
|
||||
tooltip: hasTimeFieldName
|
||||
? undefined
|
||||
: i18n.translate('discover.alerts.missedTimeFieldToolTip', {
|
||||
defaultMessage: 'Data view does not have a time field.',
|
||||
}),
|
||||
onClick: async (params) => {
|
||||
return (
|
||||
<CreateAlertFlyout
|
||||
{...params}
|
||||
discoverParams={discoverParams}
|
||||
services={services}
|
||||
stateContainer={stateContainer}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alertsDivider',
|
||||
type: AppMenuActionType.submenuHorizontalRule,
|
||||
},
|
||||
{
|
||||
id: AppMenuActionId.manageRulesAndConnectors,
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.alerts.manageRulesAndConnectors', {
|
||||
defaultMessage: 'Manage rules and connectors',
|
||||
}),
|
||||
iconType: 'tableOfContents',
|
||||
testId: 'discoverManageAlertsButton',
|
||||
href: services.application.getUrlForApp(
|
||||
'management/insightsAndAlerting/triggersActions/rules'
|
||||
),
|
||||
onClick: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
function getTimeField(dataView: DataView | undefined) {
|
||||
const dateFields = dataView?.fields.getByType('date');
|
||||
return dataView?.timeFieldName || dateFields?.[0]?.name;
|
||||
}
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMenuActionId, AppMenuActionType, AppMenuActionSecondary } from '@kbn/discover-utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const getInspectAppMenuItem = ({
|
||||
onOpenInspector,
|
||||
}: {
|
||||
onOpenInspector: () => void;
|
||||
}): AppMenuActionSecondary => {
|
||||
return {
|
||||
id: AppMenuActionId.inspect,
|
||||
type: AppMenuActionType.secondary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.localMenu.inspectTitle', {
|
||||
defaultMessage: 'Inspect',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', {
|
||||
defaultMessage: 'Open Inspector for search',
|
||||
}),
|
||||
testId: 'openInspectorButton',
|
||||
onClick: () => {
|
||||
onOpenInspector();
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMenuActionId, AppMenuActionType, AppMenuActionPrimary } from '@kbn/discover-utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const getNewSearchAppMenuItem = ({
|
||||
onNewSearch,
|
||||
}: {
|
||||
onNewSearch: () => void;
|
||||
}): AppMenuActionPrimary => {
|
||||
return {
|
||||
id: AppMenuActionId.new,
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
|
||||
defaultMessage: 'New',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.newSearchDescription', {
|
||||
defaultMessage: 'New Search',
|
||||
}),
|
||||
iconType: 'plus',
|
||||
testId: 'discoverNewButton',
|
||||
onClick: () => {
|
||||
onNewSearch();
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AppMenuActionId, AppMenuActionType, AppMenuActionPrimary } from '@kbn/discover-utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { OpenSearchPanel } from '../open_search_panel';
|
||||
|
||||
export const getOpenSearchAppMenuItem = ({
|
||||
onOpenSavedSearch,
|
||||
}: {
|
||||
onOpenSavedSearch: (savedSearchId: string) => void;
|
||||
}): AppMenuActionPrimary => {
|
||||
return {
|
||||
id: AppMenuActionId.open,
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.localMenu.openTitle', {
|
||||
defaultMessage: 'Open',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.openSavedSearchDescription', {
|
||||
defaultMessage: 'Open Saved Search',
|
||||
}),
|
||||
iconType: 'folderOpen',
|
||||
testId: 'discoverOpenButton',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
return <OpenSearchPanel onClose={onFinishAction} onOpenSavedSearch={onOpenSavedSearch} />;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMenuActionPrimary, AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils';
|
||||
import { omit } from 'lodash';
|
||||
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DiscoverStateContainer } from '../../../state_management/discover_state';
|
||||
import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data';
|
||||
import { DiscoverAppLocatorParams } from '../../../../../../common/app_locator';
|
||||
import { AppMenuDiscoverParams } from './types';
|
||||
import { DiscoverServices } from '../../../../../build_services';
|
||||
|
||||
export const getShareAppMenuItem = ({
|
||||
discoverParams,
|
||||
services,
|
||||
stateContainer,
|
||||
}: {
|
||||
discoverParams: AppMenuDiscoverParams;
|
||||
services: DiscoverServices;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
}): AppMenuActionPrimary => {
|
||||
return {
|
||||
id: AppMenuActionId.share,
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: i18n.translate('discover.localMenu.shareTitle', {
|
||||
defaultMessage: 'Share',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.shareSearchDescription', {
|
||||
defaultMessage: 'Share Search',
|
||||
}),
|
||||
iconType: 'share',
|
||||
testId: 'shareTopNavButton',
|
||||
onClick: async ({ anchorElement }) => {
|
||||
const { dataView, isEsqlMode } = discoverParams;
|
||||
|
||||
if (!services.share) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedSearch = stateContainer.savedSearchState.getState();
|
||||
const searchSourceSharingData = await getSharingData(
|
||||
savedSearch.searchSource,
|
||||
stateContainer.appState.getState(),
|
||||
services,
|
||||
isEsqlMode
|
||||
);
|
||||
|
||||
const { locator, notifications } = services;
|
||||
const appState = stateContainer.appState.getState();
|
||||
const { timefilter } = services.data.query.timefilter;
|
||||
const timeRange = timefilter.getTime();
|
||||
const refreshInterval = timefilter.getRefreshInterval();
|
||||
const filters = services.filterManager.getFilters();
|
||||
|
||||
// Share -> Get links -> Snapshot
|
||||
const params: DiscoverAppLocatorParams = {
|
||||
...omit(appState, 'dataSource'),
|
||||
...(savedSearch.id ? { savedSearchId: savedSearch.id } : {}),
|
||||
...(dataView?.isPersisted()
|
||||
? { dataViewId: dataView?.id }
|
||||
: { dataViewSpec: dataView?.toMinimalSpec() }),
|
||||
filters,
|
||||
timeRange,
|
||||
refreshInterval,
|
||||
};
|
||||
const relativeUrl = locator.getRedirectUrl(params);
|
||||
|
||||
// This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be
|
||||
// replaced when https://github.com/elastic/kibana/issues/153323 is implemented.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', relativeUrl);
|
||||
const shareableUrl = link.href;
|
||||
|
||||
// Share -> Get links -> Saved object
|
||||
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,
|
||||
allowShortUrl: !!services.capabilities.discover.createShortUrl,
|
||||
shareableUrl,
|
||||
shareableUrlForSavedObject,
|
||||
shareableUrlLocatorParams: { locator, params },
|
||||
objectId: savedSearch.id,
|
||||
objectType: 'search',
|
||||
objectTypeMeta: {
|
||||
title: i18n.translate('discover.share.shareModal.title', {
|
||||
defaultMessage: 'Share this search',
|
||||
}),
|
||||
},
|
||||
sharingData: {
|
||||
isTextBased: isEsqlMode,
|
||||
locatorParams: [{ id: locator.id, params }],
|
||||
...searchSourceSharingData,
|
||||
// CSV reports can be generated without a saved search so we provide a fallback title
|
||||
title:
|
||||
savedSearch.title ||
|
||||
i18n.translate('discover.localMenu.fallbackReportTitle', {
|
||||
defaultMessage: 'Untitled discover search',
|
||||
}),
|
||||
},
|
||||
isDirty: !savedSearch.id || stateContainer.appState.hasChanged(),
|
||||
showPublicUrlSwitch,
|
||||
onClose: () => {
|
||||
anchorElement?.focus();
|
||||
},
|
||||
toasts: notifications.toasts,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { getAlertsAppMenuItem } from './get_alerts';
|
||||
export { getNewSearchAppMenuItem } from './get_new_search';
|
||||
export { getOpenSearchAppMenuItem } from './get_open_search';
|
||||
export { getShareAppMenuItem } from './get_share';
|
||||
export { getInspectAppMenuItem } from './get_inspect';
|
||||
export { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item';
|
||||
export * from './types';
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { AppMenuActionSubmenuCustom, AppMenuActionType, AppMenuItem } from '@kbn/discover-utils';
|
||||
import { discoverServiceMock } from '../../../../../__mocks__/services';
|
||||
import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action';
|
||||
|
||||
describe('run app menu actions', () => {
|
||||
describe('runAppMenuAction', () => {
|
||||
it('should call the action correctly', () => {
|
||||
const appMenuItem: AppMenuItem = {
|
||||
id: 'action-1',
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: 'Action 1',
|
||||
testId: 'action-1',
|
||||
iconType: 'share',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const anchorElement = document.createElement('div');
|
||||
|
||||
runAppMenuAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
expect(appMenuItem.controlProps.onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the action and render a custom content', async () => {
|
||||
const appMenuItem: AppMenuItem = {
|
||||
id: 'action-1',
|
||||
type: AppMenuActionType.primary,
|
||||
controlProps: {
|
||||
label: 'Action 1',
|
||||
testId: 'action-1',
|
||||
iconType: 'share',
|
||||
onClick: jest.fn(({ onFinishAction }) => (
|
||||
<button data-test-subj="test-content" onClick={onFinishAction} />
|
||||
)),
|
||||
},
|
||||
};
|
||||
|
||||
const anchorElement = document.createElement('div');
|
||||
|
||||
await runAppMenuAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
expect(appMenuItem.controlProps.onClick).toHaveBeenCalled();
|
||||
const customButton = screen.getByTestId('test-content');
|
||||
expect(customButton).toBeInTheDocument();
|
||||
customButton.click();
|
||||
expect(screen.queryByTestId('test-content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAppMenuPopoverAction', () => {
|
||||
it('should render the submenu action correctly', async () => {
|
||||
const mockClick = jest.fn();
|
||||
const appMenuItem: AppMenuActionSubmenuCustom = {
|
||||
id: 'action-submenu',
|
||||
type: AppMenuActionType.custom,
|
||||
label: 'Action Submenu',
|
||||
testId: 'test-action-submenu',
|
||||
actions: [
|
||||
{
|
||||
id: 'action-submenu-1',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action 3.1',
|
||||
testId: 'action-submenu-1',
|
||||
onClick: mockClick,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-submenu-2',
|
||||
testId: 'action-submenu-2',
|
||||
type: AppMenuActionType.submenuHorizontalRule,
|
||||
},
|
||||
{
|
||||
id: 'action-submenu-3',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Action 3.3',
|
||||
testId: 'action-submenu-3',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const anchorElement = document.createElement('div');
|
||||
|
||||
runAppMenuPopoverAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services: discoverServiceMock,
|
||||
});
|
||||
|
||||
screen.getByTestId('action-submenu-1').click();
|
||||
expect(mockClick).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('action-submenu-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('action-submenu-3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiHorizontalRule,
|
||||
EuiWrappingPopover,
|
||||
} from '@elastic/eui';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import {
|
||||
AppMenuActionCustom,
|
||||
AppMenuActionPrimary,
|
||||
AppMenuActionSecondary,
|
||||
AppMenuActionSubmenuCustom,
|
||||
AppMenuActionSubmenuSecondary,
|
||||
AppMenuActionType,
|
||||
} from '@kbn/discover-utils';
|
||||
import type { DiscoverServices } from '../../../../../build_services';
|
||||
|
||||
const container = document.createElement('div');
|
||||
let isOpen = false;
|
||||
|
||||
interface AppMenuActionsMenuPopoverProps {
|
||||
appMenuItem: AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom;
|
||||
anchorElement: HTMLElement;
|
||||
services: DiscoverServices;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AppMenuActionsMenuPopover: React.FC<AppMenuActionsMenuPopoverProps> = ({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
onClose: originalOnClose,
|
||||
}) => {
|
||||
const [nestedContent, setNestedContent] = useState<React.ReactNode>();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
originalOnClose();
|
||||
anchorElement?.focus();
|
||||
}, [anchorElement, originalOnClose]);
|
||||
|
||||
const items = appMenuItem.actions.map((action) => {
|
||||
if (action.type === AppMenuActionType.submenuHorizontalRule) {
|
||||
return <EuiHorizontalRule key={action.id} data-test-subj={action.testId} margin="none" />;
|
||||
}
|
||||
|
||||
const controlProps = action.controlProps;
|
||||
|
||||
return (
|
||||
<EuiContextMenuItem
|
||||
key={action.id}
|
||||
data-test-subj={controlProps.testId}
|
||||
disabled={
|
||||
typeof controlProps.disableButton === 'function'
|
||||
? controlProps.disableButton()
|
||||
: Boolean(controlProps.disableButton)
|
||||
}
|
||||
toolTipContent={
|
||||
typeof controlProps.tooltip === 'function' ? controlProps.tooltip() : controlProps.tooltip
|
||||
}
|
||||
icon={controlProps.iconType}
|
||||
href={controlProps.href}
|
||||
onClick={async () => {
|
||||
const result = await controlProps.onClick?.({
|
||||
anchorElement,
|
||||
onFinishAction: onClose,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setNestedContent(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{controlProps.label}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{nestedContent}
|
||||
<EuiWrappingPopover
|
||||
ownFocus
|
||||
button={anchorElement}
|
||||
closePopover={onClose}
|
||||
isOpen={!nestedContent}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel items={items} />
|
||||
</EuiWrappingPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function cleanup() {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
export function runAppMenuPopoverAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services,
|
||||
}: {
|
||||
appMenuItem: AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom;
|
||||
anchorElement: HTMLElement;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
if (isOpen) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = true;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const element = (
|
||||
<KibanaRenderContextProvider {...services.core}>
|
||||
<KibanaContextProvider services={services}>
|
||||
<AppMenuActionsMenuPopover
|
||||
appMenuItem={appMenuItem}
|
||||
anchorElement={anchorElement}
|
||||
services={services}
|
||||
onClose={cleanup}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
ReactDOM.render(element, container);
|
||||
}
|
||||
|
||||
export async function runAppMenuAction({
|
||||
appMenuItem,
|
||||
anchorElement,
|
||||
services,
|
||||
}: {
|
||||
appMenuItem: AppMenuActionPrimary | AppMenuActionSecondary | AppMenuActionCustom;
|
||||
anchorElement: HTMLElement;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
cleanup();
|
||||
|
||||
const controlProps = appMenuItem.controlProps;
|
||||
|
||||
const result = await controlProps.onClick?.({
|
||||
anchorElement,
|
||||
onFinishAction: () => {
|
||||
cleanup();
|
||||
anchorElement?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = true;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const element = (
|
||||
<KibanaRenderContextProvider {...services.core}>
|
||||
<KibanaContextProvider services={services}>{result}</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
ReactDOM.render(element, container);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { AppMenuExtensionParams } from '../../../../../context_awareness/types';
|
||||
|
||||
export type AppMenuDiscoverParams = AppMenuExtensionParams;
|
|
@ -83,7 +83,6 @@ const mockUseKibana = useKibana as jest.Mock;
|
|||
describe('Discover topnav component', () => {
|
||||
beforeEach(() => {
|
||||
mockTopNavCustomization.defaultMenu = undefined;
|
||||
mockTopNavCustomization.getMenuItems = undefined;
|
||||
mockUseCustomizations = false;
|
||||
jest.clearAllMocks();
|
||||
|
||||
|
@ -116,7 +115,7 @@ describe('Discover topnav component', () => {
|
|||
);
|
||||
const topNavMenu = component.find(TopNavMenu);
|
||||
const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual(['new', 'open', 'share', 'inspect', 'save']);
|
||||
expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share', 'save']);
|
||||
});
|
||||
|
||||
test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => {
|
||||
|
@ -128,7 +127,7 @@ describe('Discover topnav component', () => {
|
|||
);
|
||||
const topNavMenu = component.find(TopNavMenu).props();
|
||||
const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id);
|
||||
expect(topMenuConfig).toEqual(['new', 'open', 'share', 'inspect']);
|
||||
expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share']);
|
||||
});
|
||||
|
||||
test('top nav is correct when discover saveQuery permission is granted', () => {
|
||||
|
@ -158,31 +157,6 @@ describe('Discover topnav component', () => {
|
|||
});
|
||||
|
||||
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 = {
|
||||
|
@ -203,27 +177,6 @@ describe('Discover topnav component', () => {
|
|||
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', () => {
|
||||
|
|
|
@ -25,6 +25,7 @@ import { useAppStateSelector } from '../../state_management/discover_app_state_c
|
|||
import { useDiscoverTopNav } from './use_discover_topnav';
|
||||
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
|
||||
import { ESQLToDataViewTransitionModal } from './esql_dataview_transition';
|
||||
import './top_nav.scss';
|
||||
|
||||
export interface DiscoverTopNavProps {
|
||||
savedQuery?: string;
|
||||
|
@ -193,6 +194,7 @@ export const DiscoverTopNav = ({
|
|||
badges: topNavBadges,
|
||||
config: topNavMenu,
|
||||
setMenuMountPoint: setHeaderActionMenu,
|
||||
className: 'dscTopNav', // FIXME: Delete the scss file and pass `gutterSize="xxs"` instead (after next Eui release)
|
||||
};
|
||||
}, [
|
||||
setHeaderActionMenu,
|
||||
|
|
|
@ -128,45 +128,4 @@ describe('getTopNavBadges()', function () {
|
|||
});
|
||||
expect(topNavBadges).toMatchInlineSnapshot(`Array []`);
|
||||
});
|
||||
|
||||
test('should allow to render additional badges when customized', () => {
|
||||
const topNavBadges = getTopNavBadges({
|
||||
hasUnsavedChanges: true,
|
||||
services: discoverServiceMock,
|
||||
stateContainer,
|
||||
topNavCustomization: {
|
||||
id: 'top_nav',
|
||||
getBadges: () => {
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
badgeText: 'test10',
|
||||
},
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
badgeText: 'test200',
|
||||
},
|
||||
order: 200,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(topNavBadges).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"badgeText": "test10",
|
||||
},
|
||||
Object {
|
||||
"badgeText": "Unsaved changes",
|
||||
"renderCustomBadge": [Function],
|
||||
},
|
||||
Object {
|
||||
"badgeText": "test200",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,13 +40,13 @@ export const getTopNavBadges = ({
|
|||
});
|
||||
|
||||
const defaultBadges = topNavCustomization?.defaultBadges;
|
||||
const entries = [...(topNavCustomization?.getBadges?.() ?? [])];
|
||||
const entries: TopNavMenuBadgeProps[] = [];
|
||||
|
||||
const isManaged = stateContainer.savedSearchState.getState().managed;
|
||||
|
||||
if (hasUnsavedChanges && !defaultBadges?.unsavedChangesBadge?.disabled) {
|
||||
entries.push({
|
||||
data: getTopNavUnsavedChangesBadge({
|
||||
entries.push(
|
||||
getTopNavUnsavedChangesBadge({
|
||||
onRevert: async () => {
|
||||
dismissFlyouts([DiscoverFlyouts.lensEdit]);
|
||||
await stateContainer.actions.undoSavedSearchChanges();
|
||||
|
@ -62,22 +62,20 @@ export const getTopNavBadges = ({
|
|||
await saveSearch(true);
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
order: defaultBadges?.unsavedChangesBadge?.order ?? 100,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isManaged) {
|
||||
entries.push({
|
||||
data: getManagedContentBadge(
|
||||
entries.push(
|
||||
getManagedContentBadge(
|
||||
i18n.translate('discover.topNav.managedContentLabel', {
|
||||
defaultMessage:
|
||||
'This saved search is managed by Elastic. Changes here must be saved to a new saved search.',
|
||||
})
|
||||
),
|
||||
order: 101,
|
||||
});
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.order - b.order).map((entry) => entry.data);
|
||||
return entries;
|
||||
};
|
||||
|
|
|
@ -1,154 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { getTopNavLinks } from './get_top_nav_links';
|
||||
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { DiscoverStateContainer } from '../../state_management/discover_state';
|
||||
|
||||
const services = {
|
||||
capabilities: {
|
||||
discover: {
|
||||
save: true,
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn(() => true),
|
||||
},
|
||||
} as unknown as DiscoverServices;
|
||||
|
||||
const state = {} as unknown as DiscoverStateContainer;
|
||||
|
||||
test('getTopNavLinks result', () => {
|
||||
const topNavLinks = getTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isEsqlMode: false,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
shouldShowESQLToDataViewTransitionModal: false,
|
||||
});
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"color": "text",
|
||||
"emphasize": true,
|
||||
"fill": false,
|
||||
"id": "esql",
|
||||
"label": "Try ES|QL",
|
||||
"run": [Function],
|
||||
"testId": "select-text-based-language-btn",
|
||||
"tooltip": "ES|QL is Elastic's powerful new piped query language.",
|
||||
},
|
||||
Object {
|
||||
"description": "New Search",
|
||||
"id": "new",
|
||||
"label": "New",
|
||||
"run": [Function],
|
||||
"testId": "discoverNewButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Saved Search",
|
||||
"id": "open",
|
||||
"label": "Open",
|
||||
"run": [Function],
|
||||
"testId": "discoverOpenButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Share Search",
|
||||
"id": "share",
|
||||
"label": "Share",
|
||||
"run": [Function],
|
||||
"testId": "shareTopNavButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Inspector for search",
|
||||
"id": "inspect",
|
||||
"label": "Inspect",
|
||||
"run": [Function],
|
||||
"testId": "openInspectorButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Save Search",
|
||||
"emphasize": true,
|
||||
"iconType": "save",
|
||||
"id": "save",
|
||||
"label": "Save",
|
||||
"run": [Function],
|
||||
"testId": "discoverSaveButton",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('getTopNavLinks result for ES|QL mode', () => {
|
||||
const topNavLinks = getTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isEsqlMode: true,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
shouldShowESQLToDataViewTransitionModal: false,
|
||||
});
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"color": "text",
|
||||
"emphasize": true,
|
||||
"fill": false,
|
||||
"id": "esql",
|
||||
"label": "Switch to classic",
|
||||
"run": [Function],
|
||||
"testId": "switch-to-dataviews",
|
||||
"tooltip": "Switch to KQL or Lucene syntax.",
|
||||
},
|
||||
Object {
|
||||
"description": "New Search",
|
||||
"id": "new",
|
||||
"label": "New",
|
||||
"run": [Function],
|
||||
"testId": "discoverNewButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Saved Search",
|
||||
"id": "open",
|
||||
"label": "Open",
|
||||
"run": [Function],
|
||||
"testId": "discoverOpenButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Share Search",
|
||||
"id": "share",
|
||||
"label": "Share",
|
||||
"run": [Function],
|
||||
"testId": "shareTopNavButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Inspector for search",
|
||||
"id": "inspect",
|
||||
"label": "Inspect",
|
||||
"run": [Function],
|
||||
"testId": "openInspectorButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Save Search",
|
||||
"emphasize": true,
|
||||
"iconType": "save",
|
||||
"id": "save",
|
||||
"label": "Save",
|
||||
"run": [Function],
|
||||
"testId": "discoverSaveButton",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
|
@ -1,316 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
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 { omit } from 'lodash';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import { ENABLE_ESQL } from '@kbn/esql-utils';
|
||||
import type { DiscoverAppLocatorParams } from '../../../../../common';
|
||||
import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants';
|
||||
import { showOpenSearchPanel } from './show_open_search_panel';
|
||||
import { getSharingData, showPublicUrlSwitch } from '../../../../utils/get_sharing_data';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { onSaveSearch } from './on_save_search';
|
||||
import { DiscoverStateContainer } from '../../state_management/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,
|
||||
services,
|
||||
state,
|
||||
onOpenInspector,
|
||||
isEsqlMode,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
}: {
|
||||
dataView: DataView | undefined;
|
||||
services: DiscoverServices;
|
||||
state: DiscoverStateContainer;
|
||||
onOpenInspector: () => void;
|
||||
isEsqlMode: boolean;
|
||||
adHocDataViews: DataView[];
|
||||
topNavCustomization: TopNavCustomization | undefined;
|
||||
shouldShowESQLToDataViewTransitionModal: boolean;
|
||||
}): TopNavMenuData[] => {
|
||||
const alerts = {
|
||||
id: 'alerts',
|
||||
label: i18n.translate('discover.localMenu.localMenu.alertsTitle', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.alertsDescription', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
run: async (anchorElement: HTMLElement) => {
|
||||
openAlertsPopover({
|
||||
anchorElement,
|
||||
services,
|
||||
stateContainer: state,
|
||||
adHocDataViews,
|
||||
isEsqlMode,
|
||||
});
|
||||
},
|
||||
testId: 'discoverAlertsButton',
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches from ES|QL to classic mode and vice versa
|
||||
*/
|
||||
const esqLDataViewTransitionToggle = {
|
||||
id: 'esql',
|
||||
label: isEsqlMode
|
||||
? i18n.translate('discover.localMenu.switchToClassicTitle', {
|
||||
defaultMessage: 'Switch to classic',
|
||||
})
|
||||
: i18n.translate('discover.localMenu.tryESQLTitle', {
|
||||
defaultMessage: 'Try ES|QL',
|
||||
}),
|
||||
emphasize: true,
|
||||
fill: false,
|
||||
color: 'text',
|
||||
tooltip: isEsqlMode
|
||||
? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', {
|
||||
defaultMessage: 'Switch to KQL or Lucene syntax.',
|
||||
})
|
||||
: i18n.translate('discover.localMenu.esqlTooltipLabel', {
|
||||
defaultMessage: `ES|QL is Elastic's powerful new piped query language.`,
|
||||
}),
|
||||
run: () => {
|
||||
if (dataView) {
|
||||
if (isEsqlMode) {
|
||||
services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`);
|
||||
/**
|
||||
* Display the transition modal if:
|
||||
* - the user has not dismissed the modal
|
||||
* - the user has opened and applied changes to the saved search
|
||||
*/
|
||||
if (
|
||||
shouldShowESQLToDataViewTransitionModal &&
|
||||
!services.storage.get(ESQL_TRANSITION_MODAL_KEY)
|
||||
) {
|
||||
state.internalState.transitions.setIsESQLToDataViewTransitionModalVisible(true);
|
||||
} else {
|
||||
state.actions.transitionFromESQLToDataView(dataView.id ?? '');
|
||||
}
|
||||
} else {
|
||||
state.actions.transitionFromDataViewToESQL(dataView);
|
||||
services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:try_btn_clicked`);
|
||||
}
|
||||
}
|
||||
},
|
||||
testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn',
|
||||
};
|
||||
|
||||
const newSearch = {
|
||||
id: 'new',
|
||||
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
|
||||
defaultMessage: 'New',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.newSearchDescription', {
|
||||
defaultMessage: 'New Search',
|
||||
}),
|
||||
run: () => services.locator.navigate({}),
|
||||
testId: 'discoverNewButton',
|
||||
};
|
||||
|
||||
const saveSearch = {
|
||||
id: 'save',
|
||||
label: i18n.translate('discover.localMenu.saveTitle', {
|
||||
defaultMessage: 'Save',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.saveSearchDescription', {
|
||||
defaultMessage: 'Save Search',
|
||||
}),
|
||||
testId: 'discoverSaveButton',
|
||||
iconType: 'save',
|
||||
emphasize: true,
|
||||
run: (anchorElement: HTMLElement) => {
|
||||
onSaveSearch({
|
||||
savedSearch: state.savedSearchState.getState(),
|
||||
services,
|
||||
state,
|
||||
onClose: () => {
|
||||
anchorElement?.focus();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const openSearch = {
|
||||
id: 'open',
|
||||
label: i18n.translate('discover.localMenu.openTitle', {
|
||||
defaultMessage: 'Open',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.openSavedSearchDescription', {
|
||||
defaultMessage: 'Open Saved Search',
|
||||
}),
|
||||
testId: 'discoverOpenButton',
|
||||
run: () =>
|
||||
showOpenSearchPanel({
|
||||
onOpenSavedSearch: state.actions.onOpenSavedSearch,
|
||||
services,
|
||||
}),
|
||||
};
|
||||
|
||||
const shareSearch = {
|
||||
id: 'share',
|
||||
label: i18n.translate('discover.localMenu.shareTitle', {
|
||||
defaultMessage: 'Share',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.shareSearchDescription', {
|
||||
defaultMessage: 'Share Search',
|
||||
}),
|
||||
testId: 'shareTopNavButton',
|
||||
run: async (anchorElement: HTMLElement) => {
|
||||
if (!services.share) return;
|
||||
const savedSearch = state.savedSearchState.getState();
|
||||
const searchSourceSharingData = await getSharingData(
|
||||
savedSearch.searchSource,
|
||||
state.appState.getState(),
|
||||
services,
|
||||
isEsqlMode
|
||||
);
|
||||
|
||||
const { locator, notifications } = services;
|
||||
const appState = state.appState.getState();
|
||||
const { timefilter } = services.data.query.timefilter;
|
||||
const timeRange = timefilter.getTime();
|
||||
const refreshInterval = timefilter.getRefreshInterval();
|
||||
const filters = services.filterManager.getFilters();
|
||||
|
||||
// Share -> Get links -> Snapshot
|
||||
const params: DiscoverAppLocatorParams = {
|
||||
...omit(appState, 'dataSource'),
|
||||
...(savedSearch.id ? { savedSearchId: savedSearch.id } : {}),
|
||||
...(dataView?.isPersisted()
|
||||
? { dataViewId: dataView?.id }
|
||||
: { dataViewSpec: dataView?.toMinimalSpec() }),
|
||||
filters,
|
||||
timeRange,
|
||||
refreshInterval,
|
||||
};
|
||||
const relativeUrl = locator.getRedirectUrl(params);
|
||||
|
||||
// This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be
|
||||
// replaced when https://github.com/elastic/kibana/issues/153323 is implemented.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', relativeUrl);
|
||||
const shareableUrl = link.href;
|
||||
|
||||
// Share -> Get links -> Saved object
|
||||
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,
|
||||
allowShortUrl: !!services.capabilities.discover.createShortUrl,
|
||||
shareableUrl,
|
||||
shareableUrlForSavedObject,
|
||||
shareableUrlLocatorParams: { locator, params },
|
||||
objectId: savedSearch.id,
|
||||
objectType: 'search',
|
||||
objectTypeMeta: {
|
||||
title: i18n.translate('discover.share.shareModal.title', {
|
||||
defaultMessage: 'Share this search',
|
||||
}),
|
||||
},
|
||||
sharingData: {
|
||||
isTextBased: isEsqlMode,
|
||||
locatorParams: [{ id: locator.id, params }],
|
||||
...searchSourceSharingData,
|
||||
// CSV reports can be generated without a saved search so we provide a fallback title
|
||||
title:
|
||||
savedSearch.title ||
|
||||
i18n.translate('discover.localMenu.fallbackReportTitle', {
|
||||
defaultMessage: 'Untitled discover search',
|
||||
}),
|
||||
},
|
||||
isDirty: !savedSearch.id || state.appState.hasChanged(),
|
||||
showPublicUrlSwitch,
|
||||
onClose: () => {
|
||||
anchorElement?.focus();
|
||||
},
|
||||
toasts: notifications.toasts,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const inspectSearch = {
|
||||
id: 'inspect',
|
||||
label: i18n.translate('discover.localMenu.inspectTitle', {
|
||||
defaultMessage: 'Inspect',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', {
|
||||
defaultMessage: 'Open Inspector for search',
|
||||
}),
|
||||
testId: 'openInspectorButton',
|
||||
run: () => {
|
||||
onOpenInspector();
|
||||
},
|
||||
};
|
||||
|
||||
const defaultMenu = topNavCustomization?.defaultMenu;
|
||||
const entries = [...(topNavCustomization?.getMenuItems?.() ?? [])];
|
||||
|
||||
if (services.uiSettings.get(ENABLE_ESQL)) {
|
||||
entries.push({ data: esqLDataViewTransitionToggle, order: 0 });
|
||||
}
|
||||
|
||||
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 &&
|
||||
!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);
|
||||
};
|
|
@ -1,237 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { EuiWrappingPopover, EuiContextMenu } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import {
|
||||
AlertConsumers,
|
||||
ES_QUERY_ID,
|
||||
RuleCreationValidConsumer,
|
||||
STACK_ALERTS_FEATURE_ID,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { RuleTypeMetaData } from '@kbn/alerting-plugin/common';
|
||||
import { DiscoverStateContainer } from '../../state_management/discover_state';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
|
||||
const container = document.createElement('div');
|
||||
let isOpen = false;
|
||||
|
||||
const EsQueryValidConsumer: RuleCreationValidConsumer[] = [
|
||||
AlertConsumers.INFRASTRUCTURE,
|
||||
AlertConsumers.LOGS,
|
||||
AlertConsumers.OBSERVABILITY,
|
||||
STACK_ALERTS_FEATURE_ID,
|
||||
];
|
||||
|
||||
interface AlertsPopoverProps {
|
||||
onClose: () => void;
|
||||
anchorElement: HTMLElement;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
savedQueryId?: string;
|
||||
adHocDataViews: DataView[];
|
||||
services: DiscoverServices;
|
||||
isEsqlMode?: boolean;
|
||||
}
|
||||
|
||||
interface EsQueryAlertMetaData extends RuleTypeMetaData {
|
||||
isManagementPage?: boolean;
|
||||
adHocDataViewList: DataView[];
|
||||
}
|
||||
|
||||
export function AlertsPopover({
|
||||
anchorElement,
|
||||
adHocDataViews,
|
||||
services,
|
||||
stateContainer,
|
||||
onClose: originalOnClose,
|
||||
isEsqlMode,
|
||||
}: AlertsPopoverProps) {
|
||||
const dataView = stateContainer.internalState.getState().dataView;
|
||||
const query = stateContainer.appState.getState().query;
|
||||
const dateFields = dataView?.fields.getByType('date');
|
||||
const timeField = dataView?.timeFieldName || dateFields?.[0]?.name;
|
||||
|
||||
const { triggersActionsUi } = services;
|
||||
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false);
|
||||
const onClose = useCallback(() => {
|
||||
originalOnClose();
|
||||
anchorElement?.focus();
|
||||
}, [anchorElement, originalOnClose]);
|
||||
|
||||
/**
|
||||
* Provides the default parameters used to initialize the new rule
|
||||
*/
|
||||
const getParams = useCallback(() => {
|
||||
if (isEsqlMode) {
|
||||
return {
|
||||
searchType: 'esqlQuery',
|
||||
esqlQuery: query,
|
||||
timeField,
|
||||
};
|
||||
}
|
||||
const savedQueryId = stateContainer.appState.getState().savedQuery;
|
||||
return {
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: stateContainer.savedSearchState
|
||||
.getState()
|
||||
.searchSource.getSerializedFields(),
|
||||
savedQueryId,
|
||||
};
|
||||
}, [isEsqlMode, stateContainer.appState, stateContainer.savedSearchState, query, timeField]);
|
||||
|
||||
const discoverMetadata: EsQueryAlertMetaData = useMemo(
|
||||
() => ({
|
||||
isManagementPage: false,
|
||||
adHocDataViewList: adHocDataViews,
|
||||
}),
|
||||
[adHocDataViews]
|
||||
);
|
||||
|
||||
const SearchThresholdAlertFlyout = useMemo(() => {
|
||||
if (!alertFlyoutVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFinishFlyoutInteraction = async (metadata: EsQueryAlertMetaData) => {
|
||||
await stateContainer.actions.loadDataViewList();
|
||||
stateContainer.internalState.transitions.setAdHocDataViews(metadata.adHocDataViewList);
|
||||
};
|
||||
|
||||
return triggersActionsUi?.getAddRuleFlyout({
|
||||
metadata: discoverMetadata,
|
||||
consumer: 'alerts',
|
||||
onClose: (_, metadata) => {
|
||||
onFinishFlyoutInteraction(metadata!);
|
||||
onClose();
|
||||
},
|
||||
onSave: async (metadata) => {
|
||||
onFinishFlyoutInteraction(metadata!);
|
||||
},
|
||||
canChangeTrigger: false,
|
||||
ruleTypeId: ES_QUERY_ID,
|
||||
initialValues: { params: getParams() },
|
||||
validConsumers: EsQueryValidConsumer,
|
||||
useRuleProducer: true,
|
||||
// Default to the Logs consumer if it's available. This should fall back to Stack Alerts if it's not.
|
||||
initialSelectedConsumer: AlertConsumers.LOGS,
|
||||
});
|
||||
}, [alertFlyoutVisible, triggersActionsUi, discoverMetadata, getParams, onClose, stateContainer]);
|
||||
|
||||
const hasTimeFieldName: boolean = useMemo(() => {
|
||||
if (!isEsqlMode) {
|
||||
return Boolean(dataView?.timeFieldName);
|
||||
} else {
|
||||
return Boolean(timeField);
|
||||
}
|
||||
}, [dataView?.timeFieldName, isEsqlMode, timeField]);
|
||||
|
||||
const panels = [
|
||||
{
|
||||
id: 'mainPanel',
|
||||
name: 'Alerting',
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="discover.alerts.createSearchThreshold"
|
||||
defaultMessage="Create search threshold rule"
|
||||
/>
|
||||
),
|
||||
icon: 'bell',
|
||||
onClick: () => setAlertFlyoutVisibility(true),
|
||||
disabled: !hasTimeFieldName,
|
||||
toolTipContent: hasTimeFieldName ? undefined : (
|
||||
<FormattedMessage
|
||||
id="discover.alerts.missedTimeFieldToolTip"
|
||||
defaultMessage="Data view does not have a time field."
|
||||
/>
|
||||
),
|
||||
['data-test-subj']: 'discoverCreateAlertButton',
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="discover.alerts.manageRulesAndConnectors"
|
||||
defaultMessage="Manage rules and connectors"
|
||||
/>
|
||||
),
|
||||
icon: 'tableOfContents',
|
||||
href: services?.application?.getUrlForApp(
|
||||
'management/insightsAndAlerting/triggersActions/rules'
|
||||
),
|
||||
['data-test-subj']: 'discoverManageAlertsButton',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{SearchThresholdAlertFlyout}
|
||||
<EuiWrappingPopover
|
||||
ownFocus
|
||||
button={anchorElement}
|
||||
closePopover={onClose}
|
||||
isOpen={!alertFlyoutVisible}
|
||||
panelPaddingSize="s"
|
||||
>
|
||||
<EuiContextMenu initialPanelId="mainPanel" size="s" panels={panels} />
|
||||
</EuiWrappingPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function closeAlertsPopover() {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
export function openAlertsPopover({
|
||||
anchorElement,
|
||||
stateContainer,
|
||||
services,
|
||||
adHocDataViews,
|
||||
isEsqlMode,
|
||||
}: {
|
||||
anchorElement: HTMLElement;
|
||||
stateContainer: DiscoverStateContainer;
|
||||
services: DiscoverServices;
|
||||
adHocDataViews: DataView[];
|
||||
isEsqlMode?: boolean;
|
||||
}) {
|
||||
if (isOpen) {
|
||||
closeAlertsPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = true;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const element = (
|
||||
<KibanaRenderContextProvider {...services.core}>
|
||||
<KibanaContextProvider services={services}>
|
||||
<AlertsPopover
|
||||
onClose={closeAlertsPopover}
|
||||
anchorElement={anchorElement}
|
||||
stateContainer={stateContainer}
|
||||
adHocDataViews={adHocDataViews}
|
||||
services={services}
|
||||
isEsqlMode={isEsqlMode}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
ReactDOM.render(element, container);
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { OpenSearchPanel } from './open_search_panel';
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
export function showOpenSearchPanel({
|
||||
onOpenSavedSearch,
|
||||
services,
|
||||
}: {
|
||||
onOpenSavedSearch: (id: string) => void;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = true;
|
||||
const container = document.createElement('div');
|
||||
const onClose = () => {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
isOpen = false;
|
||||
};
|
||||
|
||||
document.body.appendChild(container);
|
||||
const element = (
|
||||
<KibanaRenderContextProvider {...services.core}>
|
||||
<KibanaContextProvider services={services}>
|
||||
<OpenSearchPanel onClose={onClose} onOpenSavedSearch={onOpenSavedSearch} />
|
||||
</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
ReactDOM.render(element, container);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.dscTopNav .euiHeaderLinks__list {
|
||||
gap: $euiSizeXS;
|
||||
}
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '../../state_management/discover_state_provider';
|
||||
import type { DiscoverStateContainer } from '../../state_management/discover_state';
|
||||
import { getTopNavBadges } from './get_top_nav_badges';
|
||||
import { getTopNavLinks } from './get_top_nav_links';
|
||||
import { useTopNavLinks } from './use_top_nav_links';
|
||||
|
||||
export const useDiscoverTopNav = ({
|
||||
stateContainer,
|
||||
|
@ -55,29 +55,16 @@ export const useDiscoverTopNav = ({
|
|||
stateContainer,
|
||||
});
|
||||
|
||||
const topNavMenu = useMemo(
|
||||
() =>
|
||||
getTopNavLinks({
|
||||
dataView,
|
||||
services,
|
||||
state: stateContainer,
|
||||
onOpenInspector,
|
||||
isEsqlMode,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
}),
|
||||
[
|
||||
adHocDataViews,
|
||||
dataView,
|
||||
isEsqlMode,
|
||||
onOpenInspector,
|
||||
services,
|
||||
stateContainer,
|
||||
topNavCustomization,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
]
|
||||
);
|
||||
const topNavMenu = useTopNavLinks({
|
||||
dataView,
|
||||
services,
|
||||
state: stateContainer,
|
||||
onOpenInspector,
|
||||
isEsqlMode,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
});
|
||||
|
||||
return {
|
||||
topNavMenu,
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
|
||||
import { useTopNavLinks } from './use_top_nav_links';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
|
||||
describe('useTopNavLinks', () => {
|
||||
const services = {
|
||||
...createDiscoverServicesMock(),
|
||||
capabilities: {
|
||||
discover: {
|
||||
save: true,
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn(() => true),
|
||||
},
|
||||
} as unknown as DiscoverServices;
|
||||
|
||||
const state = getDiscoverStateMock({ isTimeBased: true });
|
||||
state.actions.setDataView(dataViewMock);
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <KibanaContextProvider services={services}>{children}</KibanaContextProvider>;
|
||||
};
|
||||
|
||||
test('useTopNavLinks result', () => {
|
||||
const topNavLinks = renderHook(
|
||||
() =>
|
||||
useTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isEsqlMode: false,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
shouldShowESQLToDataViewTransitionModal: false,
|
||||
}),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
}
|
||||
).result.current;
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"color": "text",
|
||||
"emphasize": true,
|
||||
"fill": false,
|
||||
"id": "esql",
|
||||
"label": "Try ES|QL",
|
||||
"run": [Function],
|
||||
"testId": "select-text-based-language-btn",
|
||||
"tooltip": "ES|QL is Elastic's powerful new piped query language.",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Inspector for search",
|
||||
"id": "inspect",
|
||||
"label": "Inspect",
|
||||
"run": [Function],
|
||||
"testId": "openInspectorButton",
|
||||
},
|
||||
Object {
|
||||
"description": "New Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "plus",
|
||||
"id": "new",
|
||||
"label": "New",
|
||||
"run": [Function],
|
||||
"testId": "discoverNewButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Saved Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "folderOpen",
|
||||
"id": "open",
|
||||
"label": "Open",
|
||||
"run": [Function],
|
||||
"testId": "discoverOpenButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Share Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "share",
|
||||
"id": "share",
|
||||
"label": "Share",
|
||||
"run": [Function],
|
||||
"testId": "shareTopNavButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Save Search",
|
||||
"emphasize": true,
|
||||
"iconType": "save",
|
||||
"id": "save",
|
||||
"label": "Save",
|
||||
"run": [Function],
|
||||
"testId": "discoverSaveButton",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('useTopNavLinks result for ES|QL mode', () => {
|
||||
const topNavLinks = renderHook(
|
||||
() =>
|
||||
useTopNavLinks({
|
||||
dataView: dataViewMock,
|
||||
onOpenInspector: jest.fn(),
|
||||
services,
|
||||
state,
|
||||
isEsqlMode: true,
|
||||
adHocDataViews: [],
|
||||
topNavCustomization: undefined,
|
||||
shouldShowESQLToDataViewTransitionModal: false,
|
||||
}),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
}
|
||||
).result.current;
|
||||
expect(topNavLinks).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"color": "text",
|
||||
"emphasize": true,
|
||||
"fill": false,
|
||||
"id": "esql",
|
||||
"label": "Switch to classic",
|
||||
"run": [Function],
|
||||
"testId": "switch-to-dataviews",
|
||||
"tooltip": "Switch to KQL or Lucene syntax.",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Inspector for search",
|
||||
"id": "inspect",
|
||||
"label": "Inspect",
|
||||
"run": [Function],
|
||||
"testId": "openInspectorButton",
|
||||
},
|
||||
Object {
|
||||
"description": "New Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "plus",
|
||||
"id": "new",
|
||||
"label": "New",
|
||||
"run": [Function],
|
||||
"testId": "discoverNewButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Open Saved Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "folderOpen",
|
||||
"id": "open",
|
||||
"label": "Open",
|
||||
"run": [Function],
|
||||
"testId": "discoverOpenButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Share Search",
|
||||
"iconOnly": true,
|
||||
"iconType": "share",
|
||||
"id": "share",
|
||||
"label": "Share",
|
||||
"run": [Function],
|
||||
"testId": "shareTopNavButton",
|
||||
},
|
||||
Object {
|
||||
"description": "Save Search",
|
||||
"emphasize": true,
|
||||
"iconType": "save",
|
||||
"id": "save",
|
||||
"label": "Save",
|
||||
"run": [Function],
|
||||
"testId": "discoverSaveButton",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import { ENABLE_ESQL } from '@kbn/esql-utils';
|
||||
import { AppMenuItemPrimary, AppMenuItemSecondary, AppMenuRegistry } from '@kbn/discover-utils';
|
||||
import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { onSaveSearch } from './on_save_search';
|
||||
import { DiscoverStateContainer } from '../../state_management/discover_state';
|
||||
import {
|
||||
getAlertsAppMenuItem,
|
||||
getNewSearchAppMenuItem,
|
||||
getOpenSearchAppMenuItem,
|
||||
getShareAppMenuItem,
|
||||
getInspectAppMenuItem,
|
||||
convertAppMenuItemToTopNavItem,
|
||||
AppMenuDiscoverParams,
|
||||
} from './app_menu_actions';
|
||||
import type { TopNavCustomization } from '../../../../customizations';
|
||||
import { useProfileAccessor } from '../../../../context_awareness';
|
||||
|
||||
/**
|
||||
* Helper function to build the top nav links
|
||||
*/
|
||||
export const useTopNavLinks = ({
|
||||
dataView,
|
||||
services,
|
||||
state,
|
||||
onOpenInspector,
|
||||
isEsqlMode,
|
||||
adHocDataViews,
|
||||
topNavCustomization,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
}: {
|
||||
dataView: DataView | undefined;
|
||||
services: DiscoverServices;
|
||||
state: DiscoverStateContainer;
|
||||
onOpenInspector: () => void;
|
||||
isEsqlMode: boolean;
|
||||
adHocDataViews: DataView[];
|
||||
topNavCustomization: TopNavCustomization | undefined;
|
||||
shouldShowESQLToDataViewTransitionModal: boolean;
|
||||
}): TopNavMenuData[] => {
|
||||
const discoverParams: AppMenuDiscoverParams = useMemo(
|
||||
() => ({
|
||||
isEsqlMode,
|
||||
dataView,
|
||||
adHocDataViews,
|
||||
onUpdateAdHocDataViews: async (adHocDataViewList) => {
|
||||
await state.actions.loadDataViewList();
|
||||
state.internalState.transitions.setAdHocDataViews(adHocDataViewList);
|
||||
},
|
||||
}),
|
||||
[isEsqlMode, dataView, adHocDataViews, state]
|
||||
);
|
||||
|
||||
const defaultMenu = topNavCustomization?.defaultMenu;
|
||||
|
||||
const appMenuPrimaryAndSecondaryItems: Array<AppMenuItemPrimary | AppMenuItemSecondary> =
|
||||
useMemo(() => {
|
||||
const items: Array<AppMenuItemPrimary | AppMenuItemSecondary> = [];
|
||||
if (!defaultMenu?.inspectItem?.disabled) {
|
||||
const inspectAppMenuItem = getInspectAppMenuItem({ onOpenInspector });
|
||||
items.push(inspectAppMenuItem);
|
||||
}
|
||||
|
||||
if (
|
||||
services.triggersActionsUi &&
|
||||
services.capabilities.management?.insightsAndAlerting?.triggersActions &&
|
||||
!defaultMenu?.alertsItem?.disabled
|
||||
) {
|
||||
const alertsAppMenuItem = getAlertsAppMenuItem({
|
||||
discoverParams,
|
||||
services,
|
||||
stateContainer: state,
|
||||
});
|
||||
items.push(alertsAppMenuItem);
|
||||
}
|
||||
|
||||
if (!defaultMenu?.newItem?.disabled) {
|
||||
const newSearchMenuItem = getNewSearchAppMenuItem({
|
||||
onNewSearch: () => {
|
||||
services.locator.navigate({});
|
||||
},
|
||||
});
|
||||
items.push(newSearchMenuItem);
|
||||
}
|
||||
|
||||
if (!defaultMenu?.openItem?.disabled) {
|
||||
const openSearchMenuItem = getOpenSearchAppMenuItem({
|
||||
onOpenSavedSearch: state.actions.onOpenSavedSearch,
|
||||
});
|
||||
items.push(openSearchMenuItem);
|
||||
}
|
||||
|
||||
if (!defaultMenu?.shareItem?.disabled) {
|
||||
const shareAppMenuItem = getShareAppMenuItem({
|
||||
discoverParams,
|
||||
services,
|
||||
stateContainer: state,
|
||||
});
|
||||
items.push(shareAppMenuItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [discoverParams, state, services, defaultMenu, onOpenInspector]);
|
||||
|
||||
const getAppMenuAccessor = useProfileAccessor('getAppMenu');
|
||||
const appMenuRegistry = useMemo(() => {
|
||||
const newAppMenuRegistry = new AppMenuRegistry(appMenuPrimaryAndSecondaryItems);
|
||||
const getAppMenu = getAppMenuAccessor(() => ({
|
||||
appMenuRegistry: () => newAppMenuRegistry,
|
||||
}));
|
||||
|
||||
return getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry);
|
||||
}, [getAppMenuAccessor, discoverParams, appMenuPrimaryAndSecondaryItems]);
|
||||
|
||||
return useMemo(() => {
|
||||
const entries = appMenuRegistry.getSortedItems().map((appMenuItem) =>
|
||||
convertAppMenuItemToTopNavItem({
|
||||
appMenuItem,
|
||||
services,
|
||||
})
|
||||
);
|
||||
|
||||
if (services.uiSettings.get(ENABLE_ESQL)) {
|
||||
/**
|
||||
* Switches from ES|QL to classic mode and vice versa
|
||||
*/
|
||||
const esqLDataViewTransitionToggle = {
|
||||
id: 'esql',
|
||||
label: isEsqlMode
|
||||
? i18n.translate('discover.localMenu.switchToClassicTitle', {
|
||||
defaultMessage: 'Switch to classic',
|
||||
})
|
||||
: i18n.translate('discover.localMenu.tryESQLTitle', {
|
||||
defaultMessage: 'Try ES|QL',
|
||||
}),
|
||||
emphasize: true,
|
||||
fill: false,
|
||||
color: 'text',
|
||||
tooltip: isEsqlMode
|
||||
? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', {
|
||||
defaultMessage: 'Switch to KQL or Lucene syntax.',
|
||||
})
|
||||
: i18n.translate('discover.localMenu.esqlTooltipLabel', {
|
||||
defaultMessage: `ES|QL is Elastic's powerful new piped query language.`,
|
||||
}),
|
||||
run: () => {
|
||||
if (dataView) {
|
||||
if (isEsqlMode) {
|
||||
services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`);
|
||||
/**
|
||||
* Display the transition modal if:
|
||||
* - the user has not dismissed the modal
|
||||
* - the user has opened and applied changes to the saved search
|
||||
*/
|
||||
if (
|
||||
shouldShowESQLToDataViewTransitionModal &&
|
||||
!services.storage.get(ESQL_TRANSITION_MODAL_KEY)
|
||||
) {
|
||||
state.internalState.transitions.setIsESQLToDataViewTransitionModalVisible(true);
|
||||
} else {
|
||||
state.actions.transitionFromESQLToDataView(dataView.id ?? '');
|
||||
}
|
||||
} else {
|
||||
state.actions.transitionFromDataViewToESQL(dataView);
|
||||
services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:try_btn_clicked`);
|
||||
}
|
||||
}
|
||||
},
|
||||
testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn',
|
||||
};
|
||||
entries.unshift(esqLDataViewTransitionToggle);
|
||||
}
|
||||
|
||||
if (services.capabilities.discover.save && !defaultMenu?.saveItem?.disabled) {
|
||||
const saveSearch = {
|
||||
id: 'save',
|
||||
label: i18n.translate('discover.localMenu.saveTitle', {
|
||||
defaultMessage: 'Save',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.saveSearchDescription', {
|
||||
defaultMessage: 'Save Search',
|
||||
}),
|
||||
testId: 'discoverSaveButton',
|
||||
iconType: 'save',
|
||||
emphasize: true,
|
||||
run: (anchorElement: HTMLElement) => {
|
||||
onSaveSearch({
|
||||
savedSearch: state.savedSearchState.getState(),
|
||||
services,
|
||||
state,
|
||||
onClose: () => {
|
||||
anchorElement?.focus();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
entries.push(saveSearch);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [
|
||||
services,
|
||||
appMenuRegistry,
|
||||
state,
|
||||
dataView,
|
||||
isEsqlMode,
|
||||
shouldShowESQLToDataViewTransitionModal,
|
||||
defaultMenu,
|
||||
]);
|
||||
};
|
|
@ -7,8 +7,13 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import { getFieldValue, RowControlColumn } from '@kbn/discover-utils';
|
||||
import { EuiBadge, EuiFlyout } from '@elastic/eui';
|
||||
import {
|
||||
AppMenuActionId,
|
||||
AppMenuActionType,
|
||||
getFieldValue,
|
||||
RowControlColumn,
|
||||
} from '@kbn/discover-utils';
|
||||
import { isOfAggregateQueryType } from '@kbn/es-query';
|
||||
import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
@ -73,6 +78,97 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi
|
|||
},
|
||||
};
|
||||
},
|
||||
/**
|
||||
* The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu.
|
||||
* The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation.
|
||||
* And it supports opening custom flyouts and any other modals on the click.
|
||||
* `getAppMenu` can be configured in both root and data source profiles.
|
||||
* @param prev
|
||||
*/
|
||||
getAppMenu: (prev) => (params) => {
|
||||
const prevValue = prev(params);
|
||||
|
||||
// This is what is available via params:
|
||||
// const { dataView, services, isEsqlMode, adHocDataViews, onUpdateAdHocDataViews } = params;
|
||||
|
||||
return {
|
||||
appMenuRegistry: (registry) => {
|
||||
// Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored.
|
||||
|
||||
// Can be a on-click action, link or a submenu with an array of actions and horizontal rules
|
||||
registry.registerCustomAction({
|
||||
id: 'example-custom-action',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Custom action',
|
||||
testId: 'example-custom-action',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
alert('Example Custom action clicked');
|
||||
onFinishAction(); // This allows to return focus back to the app menu DOM node
|
||||
},
|
||||
},
|
||||
// In case of a submenu, you can add actions to it under `actions`
|
||||
// actions: [
|
||||
// {
|
||||
// id: 'example-custom-action-1-1',
|
||||
// type: AppMenuActionType.custom,
|
||||
// controlProps: {
|
||||
// label: 'Custom action',
|
||||
// onClick: ({ onFinishAction }) => {
|
||||
// alert('Example Custom action clicked');
|
||||
// onFinishAction();
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// id: 'example-custom-action-1-2',
|
||||
// type: AppMenuActionType.submenuHorizontalRule
|
||||
// },
|
||||
// ...
|
||||
// ],
|
||||
});
|
||||
|
||||
// This example shows how to add a custom action under the Alerts submenu
|
||||
registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, {
|
||||
// It's also possible to override the submenu actions by using the same id
|
||||
// as `AppMenuActionId.createRule` or `AppMenuActionId.manageRulesAndConnectors`
|
||||
id: 'example-custom-action4',
|
||||
type: AppMenuActionType.custom,
|
||||
order: 101,
|
||||
controlProps: {
|
||||
label: 'Create SLO (Custom action)',
|
||||
iconType: 'visGauge',
|
||||
testId: 'example-custom-action-under-alerts',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
// This is an example of a custom action that opens a flyout or any other custom modal.
|
||||
// To do so, simply return a React element and call onFinishAction when you're done.
|
||||
return (
|
||||
<EuiFlyout onClose={onFinishAction}>
|
||||
<div>Example custom action clicked</div>
|
||||
</EuiFlyout>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// This submenu was defined in the root profile example_root_pofile/profile.tsx
|
||||
// And we can still add actions to it from the data source profile here.
|
||||
registry.registerCustomActionUnderSubmenu('example-custom-root-submenu', {
|
||||
id: 'example-custom-action5',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Custom action (from Data Source profile)',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
alert('Example Data source action under root submenu clicked');
|
||||
onFinishAction();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return prevValue.appMenuRegistry(registry);
|
||||
},
|
||||
};
|
||||
},
|
||||
getRowAdditionalLeadingControls: (prev) => (params) => {
|
||||
const additionalControls = prev(params) || [];
|
||||
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import { getFieldValue } from '@kbn/discover-utils';
|
||||
import { EuiBadge, EuiFlyout } from '@elastic/eui';
|
||||
import { AppMenuActionType, getFieldValue } from '@kbn/discover-utils';
|
||||
import React from 'react';
|
||||
import { RootProfileProvider, SolutionType } from '../../../profiles';
|
||||
|
||||
|
@ -28,6 +28,68 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({
|
|||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu.
|
||||
* The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation.
|
||||
* And it supports opening custom flyouts and any other modals on the click.
|
||||
* `getAppMenu` can be configured in both root and data source profiles.
|
||||
* @param prev
|
||||
*/
|
||||
getAppMenu: (prev) => (params) => {
|
||||
const prevValue = prev(params);
|
||||
|
||||
// Check `params` for the available deps
|
||||
|
||||
return {
|
||||
appMenuRegistry: (registry) => {
|
||||
// Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored.
|
||||
|
||||
// Register a custom submenu action
|
||||
registry.registerCustomAction({
|
||||
id: 'example-custom-root-submenu',
|
||||
type: AppMenuActionType.custom,
|
||||
label: 'Custom Submenu',
|
||||
testId: 'example-custom-root-submenu',
|
||||
actions: [
|
||||
{
|
||||
id: 'example-custom-root-action11',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Custom action 11 (from Root profile)',
|
||||
testId: 'example-custom-root-action11',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
alert('Example Root Custom action 11 clicked');
|
||||
onFinishAction(); // This allows to close the popover and return focus back to the app menu DOM node
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'example-custom-root-action12',
|
||||
type: AppMenuActionType.custom,
|
||||
controlProps: {
|
||||
label: 'Custom action 12 (from Root profile)',
|
||||
testId: 'example-custom-root-action12',
|
||||
onClick: ({ onFinishAction }) => {
|
||||
// This is an example of a custom action that opens a flyout or any other custom modal.
|
||||
// To do so, simply return a React element and call onFinishAction when you're done.
|
||||
return (
|
||||
<EuiFlyout
|
||||
onClose={onFinishAction}
|
||||
data-test-subj="example-custom-root-action12-flyout"
|
||||
>
|
||||
<div>Example custom action clicked</div>
|
||||
</EuiFlyout>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return prevValue.appMenuRegistry(registry);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
resolve: (params) => {
|
||||
if (params.solutionNavId != null) {
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {
|
|||
UnifiedDataTableProps,
|
||||
} from '@kbn/unified-data-table';
|
||||
import type { DocViewsRegistry } from '@kbn/unified-doc-viewer';
|
||||
import type { DataTableRecord } from '@kbn/discover-utils';
|
||||
import type { AppMenuRegistry, DataTableRecord } from '@kbn/discover-utils';
|
||||
import type { CellAction, CellActionExecutionContext, CellActionsData } from '@kbn/cell-actions';
|
||||
import type { EuiIconType } from '@elastic/eui/src/components/icon/icon';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
|
@ -25,6 +25,28 @@ import type { DiscoverDataSource } from '../../common/data_sources';
|
|||
import type { DiscoverAppState } from '../application/main/state_management/discover_app_state_container';
|
||||
import { DiscoverStateContainer } from '../application/main/state_management/discover_state';
|
||||
|
||||
/**
|
||||
* Supports extending the Discover app menu
|
||||
*/
|
||||
export interface AppMenuExtension {
|
||||
/**
|
||||
* Supports extending the app menu with additional actions
|
||||
* @param prevRegistry The app menu registry
|
||||
* @returns The updated app menu registry
|
||||
*/
|
||||
appMenuRegistry: (prevRegistry: AppMenuRegistry) => AppMenuRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters passed to the app menu extension
|
||||
*/
|
||||
export interface AppMenuExtensionParams {
|
||||
isEsqlMode: boolean;
|
||||
dataView: DataView | undefined;
|
||||
adHocDataViews: DataView[];
|
||||
onUpdateAdHocDataViews: (adHocDataViews: DataView[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supports customizing the Discover document viewer flyout
|
||||
*/
|
||||
|
@ -288,4 +310,20 @@ export interface Profile {
|
|||
* @returns The doc viewer extension
|
||||
*/
|
||||
getDocViewer: (params: DocViewerExtensionParams) => DocViewerExtension;
|
||||
|
||||
/**
|
||||
* App Menu (Top Nav actions)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supports extending the app menu with additional actions
|
||||
* The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu.
|
||||
* The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation.
|
||||
* And it supports opening custom flyouts and any other modals on the click.
|
||||
* `getAppMenu` can be configured in both root and data source profiles.
|
||||
* Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored.
|
||||
* @param params The app menu extension parameters
|
||||
* @returns The app menu extension
|
||||
*/
|
||||
getAppMenu: (params: AppMenuExtensionParams) => AppMenuExtension;
|
||||
}
|
||||
|
|
|
@ -7,11 +7,8 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { TopNavMenuData, TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public';
|
||||
|
||||
export interface TopNavDefaultItem {
|
||||
disabled?: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface TopNavDefaultMenu {
|
||||
|
@ -23,24 +20,12 @@ export interface TopNavDefaultMenu {
|
|||
saveItem?: TopNavDefaultItem;
|
||||
}
|
||||
|
||||
export interface TopNavMenuItem {
|
||||
data: TopNavMenuData;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TopNavDefaultBadges {
|
||||
unsavedChangesBadge?: TopNavDefaultItem;
|
||||
}
|
||||
|
||||
export interface TopNavBadge {
|
||||
data: TopNavMenuBadgeProps;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TopNavCustomization {
|
||||
id: 'top_nav';
|
||||
defaultMenu?: TopNavDefaultMenu;
|
||||
getMenuItems?: () => TopNavMenuItem[];
|
||||
defaultBadges?: TopNavDefaultBadges;
|
||||
getBadges?: () => TopNavBadge[];
|
||||
}
|
||||
|
|
|
@ -1,5 +1,23 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TopNavMenu Should render an icon-only item 1`] = `
|
||||
<EuiToolTip
|
||||
content="Test"
|
||||
delay="long"
|
||||
display="inlineBlock"
|
||||
position="bottom"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label="Test"
|
||||
color="primary"
|
||||
iconType="share"
|
||||
isDisabled={false}
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
`;
|
||||
|
||||
exports[`TopNavMenu Should render emphasized item which should be clickable 1`] = `
|
||||
<EuiButton
|
||||
color="primary"
|
||||
|
|
|
@ -28,6 +28,7 @@ export interface TopNavMenuData {
|
|||
isLoading?: boolean;
|
||||
iconType?: string;
|
||||
iconSide?: EuiButtonProps['iconSide'];
|
||||
iconOnly?: boolean;
|
||||
target?: string;
|
||||
href?: string;
|
||||
intl?: InjectedIntl;
|
||||
|
|
|
@ -11,6 +11,7 @@ import React from 'react';
|
|||
import { TopNavMenuItem } from './top_nav_menu_item';
|
||||
import { TopNavMenuData } from './top_nav_menu_data';
|
||||
import { shallowWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
|
||||
describe('TopNavMenu', () => {
|
||||
const ensureMenuItemDisabled = (data: TopNavMenuData) => {
|
||||
|
@ -76,6 +77,23 @@ describe('TopNavMenu', () => {
|
|||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Should render an icon-only item', () => {
|
||||
const data: TopNavMenuData = {
|
||||
id: 'test',
|
||||
label: 'test',
|
||||
iconType: 'share',
|
||||
iconOnly: true,
|
||||
run: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallowWithIntl(<TopNavMenuItem {...data} />);
|
||||
expect(component).toMatchSnapshot();
|
||||
|
||||
const event = { currentTarget: { value: 'a' } };
|
||||
component.find(EuiButtonIcon).simulate('click', event);
|
||||
expect(data.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should render disabled item and it shouldnt be clickable', () => {
|
||||
ensureMenuItemDisabled({
|
||||
id: 'test',
|
||||
|
|
|
@ -7,12 +7,19 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { upperFirst, isFunction } from 'lodash';
|
||||
import { upperFirst, isFunction, omit } from 'lodash';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { EuiToolTip, EuiButton, EuiHeaderLink, EuiBetaBadge, EuiButtonColor } from '@elastic/eui';
|
||||
import {
|
||||
EuiToolTip,
|
||||
EuiButton,
|
||||
EuiHeaderLink,
|
||||
EuiBetaBadge,
|
||||
EuiButtonColor,
|
||||
EuiButtonIcon,
|
||||
} from '@elastic/eui';
|
||||
import { TopNavMenuData } from './top_nav_menu_data';
|
||||
|
||||
export function TopNavMenuItem(props: TopNavMenuData) {
|
||||
export function TopNavMenuItem(props: TopNavMenuData & { isMobileMenu?: boolean }) {
|
||||
function isDisabled(): boolean {
|
||||
const val = isFunction(props.disableButton) ? props.disableButton() : props.disableButton;
|
||||
return val!;
|
||||
|
@ -59,16 +66,28 @@ export function TopNavMenuItem(props: TopNavMenuData) {
|
|||
? { onClick: undefined, href: props.href, target: props.target }
|
||||
: {};
|
||||
|
||||
// fill is not compatible with EuiHeaderLink
|
||||
const btn = props.emphasize ? (
|
||||
<EuiButton size="s" {...commonButtonProps} fill={props.fill ?? true}>
|
||||
{getButtonContainer()}
|
||||
</EuiButton>
|
||||
) : (
|
||||
<EuiHeaderLink size="s" {...commonButtonProps} {...overrideProps}>
|
||||
{getButtonContainer()}
|
||||
</EuiHeaderLink>
|
||||
);
|
||||
const btn =
|
||||
props.iconOnly && props.iconType && !props.isMobileMenu ? (
|
||||
// icon only buttons are not supported by EuiHeaderLink
|
||||
<EuiToolTip content={upperFirst(props.label || props.id!)} position="bottom" delay="long">
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
{...omit(commonButtonProps, 'iconSide')}
|
||||
iconType={props.iconType}
|
||||
display={props.emphasize && (props.fill ?? true) ? 'fill' : undefined}
|
||||
aria-label={upperFirst(props.label || props.id!)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
) : props.emphasize ? (
|
||||
// fill is not compatible with EuiHeaderLink
|
||||
<EuiButton size="s" {...commonButtonProps} fill={props.fill ?? true}>
|
||||
{getButtonContainer()}
|
||||
</EuiButton>
|
||||
) : (
|
||||
<EuiHeaderLink size="s" {...commonButtonProps} {...overrideProps}>
|
||||
{getButtonContainer()}
|
||||
</EuiHeaderLink>
|
||||
);
|
||||
|
||||
const tooltip = getTooltip();
|
||||
if (tooltip) {
|
||||
|
|
|
@ -7,11 +7,13 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiBreakpointSize, EuiHeaderLinks } from '@elastic/eui';
|
||||
import { EuiBreakpointSize, EuiHeaderLinks, useIsWithinBreakpoints } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { TopNavMenuData } from './top_nav_menu_data';
|
||||
import { TopNavMenuItem } from './top_nav_menu_item';
|
||||
|
||||
const POPOVER_BREAKPOINTS: EuiBreakpointSize[] = ['xs', 's'];
|
||||
|
||||
interface TopNavMenuItemsProps {
|
||||
config: TopNavMenuData[] | undefined;
|
||||
className?: string;
|
||||
|
@ -21,8 +23,10 @@ interface TopNavMenuItemsProps {
|
|||
export const TopNavMenuItems = ({
|
||||
config,
|
||||
className,
|
||||
popoverBreakpoints,
|
||||
popoverBreakpoints = POPOVER_BREAKPOINTS,
|
||||
}: TopNavMenuItemsProps) => {
|
||||
const isMobileMenu = useIsWithinBreakpoints(popoverBreakpoints);
|
||||
|
||||
if (!config || config.length === 0) return null;
|
||||
return (
|
||||
<EuiHeaderLinks
|
||||
|
@ -32,7 +36,7 @@ export const TopNavMenuItems = ({
|
|||
popoverBreakpoints={popoverBreakpoints}
|
||||
>
|
||||
{config.map((menuItem: TopNavMenuData, i: number) => {
|
||||
return <TopNavMenuItem key={`nav-menu-${i}`} {...menuItem} />;
|
||||
return <TopNavMenuItem key={`nav-menu-${i}`} isMobileMenu={isMobileMenu} {...menuItem} />;
|
||||
})}
|
||||
</EuiHeaderLinks>
|
||||
);
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
|
||||
import { AttributeService, type EmbeddableStart } from '@kbn/embeddable-plugin/public';
|
||||
import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks';
|
||||
import { SavedSearchByValueAttributes, byValueToSavedSearch } from '.';
|
||||
|
@ -21,12 +20,7 @@ const mockServices = {
|
|||
spaces: spacesPluginMock.createStartContract(),
|
||||
embeddable: {
|
||||
getAttributeService: jest.fn(
|
||||
(_, opts) =>
|
||||
new AttributeService(
|
||||
SEARCH_EMBEDDABLE_TYPE,
|
||||
coreMock.createStart().notifications.toasts,
|
||||
opts
|
||||
)
|
||||
(_, opts) => new AttributeService('search', coreMock.createStart().notifications.toasts, opts)
|
||||
),
|
||||
} as unknown as EmbeddableStart,
|
||||
};
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
"@kbn/core-plugins-server",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/search-types",
|
||||
"@kbn/discover-utils",
|
||||
"@kbn/unified-data-table",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
|
|
|
@ -48,15 +48,9 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import kbnRison from '@kbn/rison';
|
||||
import type { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const { common, discover, header } = getPageObjects([
|
||||
'common',
|
||||
'timePicker',
|
||||
'discover',
|
||||
'header',
|
||||
]);
|
||||
const esArchiver = getService('esArchiver');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
describe('extension getAppMenu', () => {
|
||||
before(async () => {
|
||||
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
});
|
||||
|
||||
it('should render the main actions and the action from root profile', async () => {
|
||||
const state = kbnRison.encode({
|
||||
dataSource: { type: 'esql' },
|
||||
query: { esql: 'from logstash* | sort @timestamp desc' },
|
||||
});
|
||||
await common.navigateToActualUrl('discover', `?_a=${state}`, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await testSubjects.existOrFail('discoverNewButton');
|
||||
await testSubjects.existOrFail('discoverAlertsButton');
|
||||
await testSubjects.existOrFail('example-custom-root-submenu');
|
||||
});
|
||||
|
||||
it('should render custom actions', async () => {
|
||||
const state = kbnRison.encode({
|
||||
dataSource: { type: 'esql' },
|
||||
query: { esql: 'from my-example-logs | sort @timestamp desc' },
|
||||
});
|
||||
await common.navigateToActualUrl('discover', `?_a=${state}`, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await testSubjects.existOrFail('discoverNewButton');
|
||||
await testSubjects.existOrFail('discoverAlertsButton');
|
||||
await testSubjects.existOrFail('example-custom-root-submenu');
|
||||
await testSubjects.existOrFail('example-custom-action');
|
||||
|
||||
await testSubjects.click('example-custom-root-submenu');
|
||||
await testSubjects.existOrFail('example-custom-root-action12');
|
||||
|
||||
await testSubjects.click('example-custom-root-action12');
|
||||
await testSubjects.existOrFail('example-custom-root-action12-flyout');
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
|
||||
await testSubjects.click('discoverAlertsButton');
|
||||
await testSubjects.existOrFail('example-custom-action-under-alerts');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -45,5 +45,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
|
|||
loadTestFile(require.resolve('./extensions/_get_cell_renderers'));
|
||||
loadTestFile(require.resolve('./extensions/_get_default_app_state'));
|
||||
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
|
||||
loadTestFile(require.resolve('./extensions/_get_app_menu'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -164,6 +164,7 @@ export class DiscoverPageObject extends FtrService {
|
|||
|
||||
public async clickNewSearchButton() {
|
||||
await this.testSubjects.click('discoverNewButton');
|
||||
await this.testSubjects.moveMouseTo('unifiedFieldListSidebar__toggle-collapse'); // cancel tooltips
|
||||
await this.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import kbnRison from '@kbn/rison';
|
||||
import type { FtrProviderContext } from '../../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const { common, discover, header, timePicker, svlCommonPage } = getPageObjects([
|
||||
'common',
|
||||
'timePicker',
|
||||
'discover',
|
||||
'header',
|
||||
'timePicker',
|
||||
'svlCommonPage',
|
||||
]);
|
||||
const esArchiver = getService('esArchiver');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
describe('extension getAppMenu', () => {
|
||||
before(async () => {
|
||||
await svlCommonPage.loginAsAdmin();
|
||||
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
});
|
||||
|
||||
it('should render the main actions', async () => {
|
||||
const state = kbnRison.encode({
|
||||
dataSource: { type: 'esql' },
|
||||
query: { esql: 'from logstash* | sort @timestamp desc' },
|
||||
});
|
||||
await common.navigateToActualUrl('discover', `?_a=${state}`, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await timePicker.setDefaultAbsoluteRange();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await testSubjects.existOrFail('discoverNewButton');
|
||||
await testSubjects.existOrFail('discoverAlertsButton');
|
||||
});
|
||||
|
||||
it('should render custom actions', async () => {
|
||||
const state = kbnRison.encode({
|
||||
dataSource: { type: 'esql' },
|
||||
query: { esql: 'from my-example-logs | sort @timestamp desc' },
|
||||
});
|
||||
await common.navigateToActualUrl('discover', `?_a=${state}`, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await timePicker.setDefaultAbsoluteRange();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await testSubjects.existOrFail('discoverNewButton');
|
||||
await testSubjects.existOrFail('discoverAlertsButton');
|
||||
await testSubjects.existOrFail('example-custom-action');
|
||||
|
||||
await testSubjects.click('discoverAlertsButton');
|
||||
await testSubjects.existOrFail('example-custom-action-under-alerts');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -43,5 +43,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
|
|||
loadTestFile(require.resolve('./extensions/_get_cell_renderers'));
|
||||
loadTestFile(require.resolve('./extensions/_get_default_app_state'));
|
||||
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
|
||||
loadTestFile(require.resolve('./extensions/_get_app_menu'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -46,15 +46,9 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue