[8.x] [OneDiscover] Contextual App Menu Extension Point (#195448) (#198320)

# 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![Oct-16-2024\r\n17-43-29](https://github.com/user-attachments/assets/dbb67513-05bb-43a4-bd7b-cf958c58a167)\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![Oct-16-2024\r\n17-43-29](https://github.com/user-attachments/assets/dbb67513-05bb-43a4-bd7b-cf958c58a167)\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![Oct-16-2024\r\n17-43-29](https://github.com/user-attachments/assets/dbb67513-05bb-43a4-bd7b-cf958c58a167)\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:
Kibana Machine 2024-10-31 01:25:31 +11:00 committed by GitHub
parent 5c8784363d
commit adf6b7dced
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2564 additions and 1062 deletions

View file

@ -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,
},
];
},
});

View file

@ -13,7 +13,6 @@
"@kbn/i18n-react",
"@kbn/react-kibana-context-theme",
"@kbn/data-plugin",
"@kbn/react-kibana-context-render",
],
"exclude": ["target/**/*"]
}

View file

@ -56,6 +56,7 @@ export {
getVisibleColumns,
canPrependTimeFieldColumn,
DiscoverFlyouts,
AppMenuRegistry,
dismissAllFlyoutsExceptFor,
dismissFlyouts,
LogLevelBadge,

View 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",
},
]
`;

View file

@ -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(),
},
},
],
},
]);
}

View file

@ -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,
};
});
}

View 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;

View file

@ -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';

View file

@ -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>>;

View file

@ -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"
]
}

View file

@ -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),
});
});
});

View file

@ -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 }
: {}),
};
}

View file

@ -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()}
/>
);
};

View file

@ -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;
}

View file

@ -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();
},
},
};
};

View file

@ -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();
},
},
};
};

View file

@ -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} />;
},
},
};
};

View file

@ -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,
});
},
},
};
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", 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';

View file

@ -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();
});
});
});

View file

@ -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);
}

View file

@ -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;

View file

@ -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', () => {

View file

@ -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,

View file

@ -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",
},
]
`);
});
});

View file

@ -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;
};

View file

@ -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",
},
]
`);
});

View file

@ -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);
};

View file

@ -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);
}

View file

@ -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);
}

View file

@ -0,0 +1,3 @@
.dscTopNav .euiHeaderLinks__list {
gap: $euiSizeXS;
}

View file

@ -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,

View file

@ -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",
},
]
`);
});
});

View file

@ -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,
]);
};

View file

@ -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) || [];

View file

@ -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) {

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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"

View file

@ -28,6 +28,7 @@ export interface TopNavMenuData {
isLoading?: boolean;
iconType?: string;
iconSide?: EuiButtonProps['iconSide'];
iconOnly?: boolean;
target?: string;
href?: string;
intl?: InjectedIntl;

View file

@ -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',

View file

@ -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) {

View file

@ -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>
);

View file

@ -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,
};

View file

@ -27,7 +27,6 @@
"@kbn/core-plugins-server",
"@kbn/utility-types",
"@kbn/search-types",
"@kbn/discover-utils",
"@kbn/unified-data-table",
],
"exclude": ["target/**/*"]

View file

@ -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 () => {

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", 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');
});
});
}

View file

@ -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'));
});
}

View file

@ -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();
}

View file

@ -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');
});
});
}

View file

@ -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'));
});
}

View file

@ -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 () => {