mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 10:23:14 -04:00
## Summary **Reviewers: Please test the code paths affected by this PR. See the "Risks" section below.** Part of work for enabling "high contrast mode" in Kibana. See https://github.com/elastic/kibana/issues/176219. **Background:** Kibana will soon have a user profile setting to allow users to enable "high contrast mode." This setting will activate a flag with `<EuiProvider>` that causes EUI components to render with higher contrast visual elements. Consumer plugins and packages need to be updated selected places where `<EuiProvider>` is wrapped, to pass the `UserProfileService` service dependency from the CoreStart contract. **NOTE:** **EUI currently does not yet support the high-contrast mode flag**, but support for that is expected to come in around 2 weeks. These first PRs are simply preparing the code by wiring up the `UserProvideService`. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [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] 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) ### Risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [medium/high] The implementor of this change did not manually test the affected code paths and relied on type-checking and functional tests to drive the changes. Code owners for this PR need to manually test the affected code paths. - [ ] [medium] The `UserProfileService` dependency comes from the CoreStart contract. If acquiring the service causes synchronous code to become asynchronous, check for race conditions or errors in rendering React components. Code owners for this PR need to manually test the affected code paths. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.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 [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 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');
|
|
};
|
|
};
|
|
}
|
|
}
|