mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 03:01:21 -04:00
## Summary This PR restructures Discover's state management to support tabs as outlined in #215398, including the Redux store and `RuntimeStateManager`. It also adds the initial tabs implementation to the UI to start building on, but they're disabled by default with a hardcoded flag. Tabs can be enabled by setting `TABS_ENABLED = true` in `discover_main_route`, but they don't need to be thoroughly tested in this PR since most of the functionality is incomplete. There's also a flaw in the state management approach with `currentId` since depending on it can cause state to leak across tabs when switching tabs during async operations (e.g. data fetching). This shouldn't be an issue while tabs are disabled, and there will be a followup PR #215620 to address it. https://github.com/user-attachments/assets/ebbb9fa7-a3bc-4e82-9b5c-0d29cd0575f0 Part of #215398. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
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 [coreStart, { discover, data }] = await core.getStartServices();
|
|
|
|
ReactDOM.render(
|
|
<I18nProvider>
|
|
<KibanaThemeProvider {...coreStart}>
|
|
<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 currentTabId = stateContainer.internalState.getState().tabs.currentId;
|
|
const dataView = useObservable(
|
|
stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$,
|
|
stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$.getValue()
|
|
);
|
|
|
|
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.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');
|
|
};
|
|
};
|
|
}
|
|
}
|