kibana/examples/discover_customization_examples/public/plugin.tsx
Julia Rechkunova 811a23830b
[OneDiscover] Contextual App Menu Extension Point (#195448)
- 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.

![Oct-16-2024
17-43-29](https://github.com/user-attachments/assets/dbb67513-05bb-43a4-bd7b-cf958c58a167)


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>
2024-10-30 13:35:15 +01:00

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