mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 02:09:32 -04:00
- Closes https://github.com/elastic/kibana/issues/194269
## Summary
This PR introduces a new extension point `getAppMenu` which allows to:
- add custom App Menu items (as a button or a submenu with more actions)
- extend Alerts menu item with more custom actions
Additionally, this PR rearranges the existing Discover menu items. The
primary actions are rendered as an icon only now.

The example usage of the new extension point can be found in
e7964f08e3/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx (L81-L168)
### For testing with the example profile
1. Add `discover.experimental.enabledProfiles: ['example-root-profile',
'example-data-source-profile', 'example-document-profile']` to
`kibana.dev.yml`
2. Run the following in DevTools
```
POST _aliases
{
"actions": [
{
"add": {
"index": "kibana_sample_data_logs",
"alias": "my-example-logs"
}
}
]
}
```
3. Create and use Data View with `my-custom-logs` index pattern
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Davis McPhee <davis.mcphee@elastic.co>
Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/*
|
|
* 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 { 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 type {
|
|
CustomizationCallback,
|
|
DiscoverSetup,
|
|
DiscoverStart,
|
|
} from '@kbn/discover-plugin/public';
|
|
import React, { useEffect, useState } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import useObservable from 'react-use/lib/useObservable';
|
|
import { ControlGroupRendererApi, ControlGroupRenderer } from '@kbn/controls-plugin/public';
|
|
import { css } from '@emotion/react';
|
|
import type { ControlPanelsState } from '@kbn/controls-plugin/common';
|
|
import { Route, Router, Routes } from '@kbn/shared-ux-router';
|
|
import { I18nProvider } from '@kbn/i18n-react';
|
|
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
|
|
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
|
import image from './discover_customization_examples.png';
|
|
|
|
export interface DiscoverCustomizationExamplesSetupPlugins {
|
|
developerExamples: DeveloperExamplesSetup;
|
|
discover: DiscoverSetup;
|
|
}
|
|
|
|
export interface DiscoverCustomizationExamplesStartPlugins {
|
|
discover: DiscoverStart;
|
|
data: DataPublicPluginStart;
|
|
}
|
|
|
|
const PLUGIN_ID = 'discoverCustomizationExamples';
|
|
const PLUGIN_NAME = 'Discover Customizations';
|
|
|
|
export class DiscoverCustomizationExamplesPlugin implements Plugin {
|
|
private customizationCallback: CustomizationCallback = () => {};
|
|
|
|
setup(
|
|
core: CoreSetup<DiscoverCustomizationExamplesStartPlugins, void>,
|
|
plugins: DiscoverCustomizationExamplesSetupPlugins
|
|
) {
|
|
core.application.register({
|
|
id: PLUGIN_ID,
|
|
title: PLUGIN_NAME,
|
|
visibleIn: [],
|
|
mount: async (appMountParams) => {
|
|
const [_, { discover, data }] = await core.getStartServices();
|
|
|
|
ReactDOM.render(
|
|
<I18nProvider>
|
|
<KibanaThemeProvider theme={core.theme}>
|
|
<Router history={appMountParams.history}>
|
|
<Routes>
|
|
<Route>
|
|
<discover.DiscoverContainer
|
|
overrideServices={{
|
|
setHeaderActionMenu: appMountParams.setHeaderActionMenu,
|
|
}}
|
|
scopedHistory={appMountParams.history}
|
|
customizationCallbacks={[this.customizationCallback]}
|
|
/>
|
|
</Route>
|
|
</Routes>
|
|
</Router>
|
|
</KibanaThemeProvider>
|
|
</I18nProvider>,
|
|
appMountParams.element
|
|
);
|
|
|
|
return () => {
|
|
// work around race condition between unmount effect and current app id
|
|
// observable in the search session service
|
|
data.search.session.clear();
|
|
|
|
ReactDOM.unmountComponentAtNode(appMountParams.element);
|
|
};
|
|
},
|
|
});
|
|
|
|
plugins.developerExamples.register({
|
|
appId: PLUGIN_ID,
|
|
title: PLUGIN_NAME,
|
|
description: 'Example plugin that uses the Discover customization framework.',
|
|
image,
|
|
});
|
|
}
|
|
|
|
start(core: CoreStart, plugins: DiscoverCustomizationExamplesStartPlugins) {
|
|
this.customizationCallback = ({ customizations, stateContainer }) => {
|
|
customizations.set({
|
|
id: 'top_nav',
|
|
defaultMenu: {
|
|
newItem: { disabled: true },
|
|
openItem: { disabled: true },
|
|
alertsItem: { disabled: true },
|
|
inspectItem: { disabled: true },
|
|
},
|
|
});
|
|
|
|
customizations.set({
|
|
id: 'search_bar',
|
|
CustomDataViewPicker: () => {
|
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
const togglePopover = () => setIsPopoverOpen((open) => !open);
|
|
const closePopover = () => setIsPopoverOpen(false);
|
|
const [savedSearches, setSavedSearches] = useState<
|
|
Array<SimpleSavedObject<{ title: string }>>
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
core.savedObjects.client
|
|
.find<{ title: string }>({ type: 'search' })
|
|
.then((response) => {
|
|
setSavedSearches(response.savedObjects);
|
|
});
|
|
}, []);
|
|
|
|
const currentSavedSearch = useObservable(
|
|
stateContainer.savedSearchState.getCurrent$(),
|
|
stateContainer.savedSearchState.getState()
|
|
);
|
|
|
|
return (
|
|
<EuiFlexItem grow={false}>
|
|
<EuiPopover
|
|
button={
|
|
<EuiButton
|
|
iconType="arrowDown"
|
|
iconSide="right"
|
|
fullWidth
|
|
onClick={togglePopover}
|
|
data-test-subj="logsViewSelectorButton"
|
|
>
|
|
{currentSavedSearch.title ?? 'None selected'}
|
|
</EuiButton>
|
|
}
|
|
isOpen={isPopoverOpen}
|
|
panelPaddingSize="none"
|
|
closePopover={closePopover}
|
|
>
|
|
<EuiContextMenu
|
|
size="s"
|
|
initialPanelId={0}
|
|
panels={[
|
|
{
|
|
id: 0,
|
|
title: 'Saved logs views',
|
|
items: savedSearches.map((savedSearch) => ({
|
|
name: savedSearch.get('title'),
|
|
onClick: () => stateContainer.actions.onOpenSavedSearch(savedSearch.id),
|
|
icon: savedSearch.id === currentSavedSearch.id ? 'check' : 'empty',
|
|
'data-test-subj': `logsViewSelectorOption-${savedSearch.attributes.title.replace(
|
|
/[^a-zA-Z0-9]/g,
|
|
''
|
|
)}`,
|
|
})),
|
|
},
|
|
]}
|
|
/>
|
|
</EuiPopover>
|
|
</EuiFlexItem>
|
|
);
|
|
},
|
|
});
|
|
|
|
customizations.set({
|
|
id: 'search_bar',
|
|
CustomDataViewPicker: () => {
|
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
const togglePopover = () => setIsPopoverOpen((open) => !open);
|
|
const closePopover = () => setIsPopoverOpen(false);
|
|
const [savedSearches, setSavedSearches] = useState<
|
|
Array<SimpleSavedObject<{ title: string }>>
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
core.savedObjects.client
|
|
.find<{ title: string }>({ type: 'search' })
|
|
.then((response) => {
|
|
setSavedSearches(response.savedObjects);
|
|
});
|
|
}, []);
|
|
|
|
const currentSavedSearch = useObservable(
|
|
stateContainer.savedSearchState.getCurrent$(),
|
|
stateContainer.savedSearchState.getState()
|
|
);
|
|
|
|
return (
|
|
<EuiFlexItem grow={false}>
|
|
<EuiPopover
|
|
button={
|
|
<EuiButton
|
|
iconType="arrowDown"
|
|
iconSide="right"
|
|
fullWidth
|
|
onClick={togglePopover}
|
|
data-test-subj="logsViewSelectorButton"
|
|
>
|
|
{currentSavedSearch.title ?? 'None selected'}
|
|
</EuiButton>
|
|
}
|
|
isOpen={isPopoverOpen}
|
|
panelPaddingSize="none"
|
|
closePopover={closePopover}
|
|
>
|
|
<EuiContextMenu
|
|
size="s"
|
|
initialPanelId={0}
|
|
panels={[
|
|
{
|
|
id: 0,
|
|
title: 'Saved logs views',
|
|
items: savedSearches.map((savedSearch) => ({
|
|
name: savedSearch.get('title'),
|
|
onClick: () => stateContainer.actions.onOpenSavedSearch(savedSearch.id),
|
|
icon: savedSearch.id === currentSavedSearch.id ? 'check' : 'empty',
|
|
'data-test-subj': `logsViewSelectorOption-${savedSearch.attributes.title.replace(
|
|
/[^a-zA-Z0-9]/g,
|
|
''
|
|
)}`,
|
|
})),
|
|
},
|
|
]}
|
|
/>
|
|
</EuiPopover>
|
|
</EuiFlexItem>
|
|
);
|
|
},
|
|
PrependFilterBar: () => {
|
|
const [controlGroupAPI, setControlGroupAPI] = useState<
|
|
ControlGroupRendererApi | undefined
|
|
>();
|
|
const stateStorage = stateContainer.stateStorage;
|
|
const dataView = useObservable(
|
|
stateContainer.internalState.state$,
|
|
stateContainer.internalState.getState()
|
|
).dataView;
|
|
|
|
useEffect(() => {
|
|
if (!controlGroupAPI) {
|
|
return;
|
|
}
|
|
|
|
const stateSubscription = stateStorage
|
|
.change$<ControlPanelsState>('controlPanels')
|
|
.subscribe((panels) =>
|
|
controlGroupAPI.updateInput({ initialChildControlState: panels ?? undefined })
|
|
);
|
|
|
|
const inputSubscription = controlGroupAPI.getInput$().subscribe((input) => {
|
|
if (input && input.initialChildControlState)
|
|
stateStorage.set('controlPanels', input.initialChildControlState);
|
|
});
|
|
|
|
const filterSubscription = controlGroupAPI.filters$.subscribe((newFilters = []) => {
|
|
stateContainer.internalState.transitions.setCustomFilters(newFilters);
|
|
stateContainer.actions.fetchData();
|
|
});
|
|
|
|
return () => {
|
|
stateSubscription.unsubscribe();
|
|
inputSubscription.unsubscribe();
|
|
filterSubscription.unsubscribe();
|
|
};
|
|
}, [controlGroupAPI, stateStorage]);
|
|
|
|
const fieldToFilterOn = dataView?.fields.filter((field) =>
|
|
field.esTypes?.includes('keyword')
|
|
)[0];
|
|
|
|
if (!fieldToFilterOn) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<EuiFlexItem
|
|
data-test-subj="customPrependedFilter"
|
|
grow={false}
|
|
css={css`
|
|
.controlGroup {
|
|
min-height: unset;
|
|
}
|
|
|
|
.euiFormLabel {
|
|
padding-top: 0;
|
|
padding-bottom: 0;
|
|
line-height: 32px !important;
|
|
}
|
|
|
|
.euiFormControlLayout {
|
|
height: 32px;
|
|
}
|
|
`}
|
|
>
|
|
<ControlGroupRenderer
|
|
onApiAvailable={setControlGroupAPI}
|
|
getCreationOptions={async (initialState, builder) => {
|
|
const panels = stateStorage.get<ControlPanelsState>('controlPanels');
|
|
|
|
if (!panels) {
|
|
builder.addOptionsListControl(initialState, {
|
|
dataViewId: dataView?.id!,
|
|
title: fieldToFilterOn.name.split('.')[0],
|
|
fieldName: fieldToFilterOn.name,
|
|
grow: false,
|
|
width: 'small',
|
|
});
|
|
}
|
|
|
|
return {
|
|
initialState: {
|
|
...initialState,
|
|
initialChildControlState: panels ?? initialState.initialChildControlState,
|
|
},
|
|
};
|
|
}}
|
|
filters={stateContainer.appState.get().filters ?? []}
|
|
/>
|
|
</EuiFlexItem>
|
|
);
|
|
},
|
|
});
|
|
|
|
customizations.set({
|
|
id: 'flyout',
|
|
size: 650,
|
|
title: 'Example custom flyout',
|
|
actions: {
|
|
getActionItems: () =>
|
|
Array.from({ length: 5 }, (_, i) => {
|
|
const index = i + 1;
|
|
return {
|
|
id: `action-item-${index}`,
|
|
enabled: true,
|
|
label: `Action ${index}`,
|
|
iconType: ['faceHappy', 'faceNeutral', 'faceSad', 'infinity', 'bell'].at(
|
|
i
|
|
) as IconType,
|
|
dataTestSubj: `customActionItem${index}`,
|
|
onClick: () => alert(index),
|
|
};
|
|
}),
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log('Cleaning up Logs explorer customizations');
|
|
};
|
|
};
|
|
}
|
|
}
|