[Logs+] Add Filter Control Customization Point (#162013)

closes https://github.com/elastic/kibana/issues/158561

## 📝  Summary

This PR adds a new customization point to allow for prepending custom
filter controls to the search bar.
At the moment we are only showing a default namespace filter, once this
is ready we will then check how to provide curated filters per
integration.

##   Testing

1. Make sure to have some documents to different data sets with
different namespace, you can use [this
document](https://www.elastic.co/guide/en/elasticsearch/reference/current/set-up-a-data-stream.html)
as an example
2. Navigate to Discover with the log-explorer profile, `/p/log-explorer`
3. Validate that the new filter control is there
4. Filter using this new control and make sure documents are filtered
out

## 🎥 Demo


6828f62f-dd09-42bd-930c-dd7eaf94958b

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
mohamedhamed-ahmed 2023-08-02 10:35:27 +01:00 committed by GitHub
parent 10c09d140a
commit 0c9afa1442
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 819 additions and 52 deletions

View file

@ -6,6 +6,6 @@
"id": "discoverCustomizationExamples",
"server": false,
"browser": true,
"requiredPlugins": ["developerExamples", "discover"]
"requiredPlugins": ["controls", "developerExamples", "discover", "embeddable", "kibanaUtils"]
}
}

View file

@ -26,6 +26,10 @@ import { noop } from 'lodash';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { css } from '@emotion/react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { ControlsPanels } from '@kbn/controls-plugin/common';
import image from './discover_customization_examples.png';
export interface DiscoverCustomizationExamplesSetupPlugins {
@ -228,6 +232,166 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
},
});
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>
}
anchorClassName="eui-fullWidth"
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<AwaitingControlGroupAPI>();
const stateStorage = stateContainer.stateStorage;
const dataView = useObservable(
stateContainer.internalState.state$,
stateContainer.internalState.getState()
).dataView;
useEffect(() => {
if (!controlGroupAPI) {
return;
}
const stateSubscription = stateStorage
.change$<ControlsPanels>('controlPanels')
.subscribe((panels) =>
controlGroupAPI.updateInput({ panels: panels ?? undefined })
);
const inputSubscription = controlGroupAPI.getInput$().subscribe((input) => {
if (input && input.panels) stateStorage.set('controlPanels', input.panels);
});
const filterSubscription = controlGroupAPI.onFiltersPublished$.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
ref={setControlGroupAPI}
getCreationOptions={async (initialInput, builder) => {
const panels = stateStorage.get<ControlsPanels>('controlPanels');
if (!panels) {
await builder.addOptionsListControl(initialInput, {
dataViewId: dataView?.id!,
title: fieldToFilterOn.name.split('.')[0],
fieldName: fieldToFilterOn.name,
grow: false,
width: 'small',
});
}
return {
initialInput: {
...initialInput,
panels: panels ?? initialInput.panels,
viewMode: ViewMode.VIEW,
filters: stateContainer.appState.get().filters ?? [],
},
};
}}
/>
</EuiFlexItem>
);
},
});
return () => {
// eslint-disable-next-line no-console
console.log('Cleaning up Logs explorer customizations');

View file

@ -8,6 +8,8 @@
"@kbn/core",
"@kbn/discover-plugin",
"@kbn/developer-examples-plugin",
"@kbn/controls-plugin",
"@kbn/embeddable-plugin",
],
"exclude": ["target/**/*"]
}

View file

@ -144,4 +144,10 @@ export const optionsListReducers = {
) => {
state.output.dataViewId = action.payload;
},
setExplicitInputDataViewId: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string>
) => {
state.explicitInput.dataViewId = action.payload;
},
};

View file

@ -215,6 +215,7 @@ export const useDiscoverHistogram = ({
* Request params
*/
const { query, filters } = useQuerySubscriber({ data: services.data });
const customFilters = useInternalStateSelector((state) => state.customFilters);
const timefilter = services.data.query.timefilter.timefilter;
const timeRange = timefilter.getAbsoluteTime();
const relativeTimeRange = useObservable(
@ -305,7 +306,7 @@ export const useDiscoverHistogram = ({
services: { ...services, uiActions: getUiActions() },
dataView: isPlainRecord ? textBasedDataView : dataView,
query: isPlainRecord ? textBasedQuery : query,
filters,
filters: [...(filters ?? []), ...customFilters],
timeRange,
relativeTimeRange,
columns,

View file

@ -234,6 +234,11 @@ export const DiscoverTopNav = ({
textBasedLanguageModeErrors ? [textBasedLanguageModeErrors] : undefined
}
onTextBasedSavedAndExit={onTextBasedSavedAndExit}
prependFilterBar={
searchBarCustomization?.PrependFilterBar ? (
<searchBarCustomization.PrependFilterBar />
) : undefined
}
/>
);
};

View file

@ -27,6 +27,7 @@ import { validateTimeRange } from '../utils/validate_time_range';
import { fetchAll } from '../utils/fetch_all';
import { sendResetMsg } from '../hooks/use_saved_search_messages';
import { getFetch$ } from '../utils/get_fetch_observable';
import { InternalState } from './discover_internal_state_container';
export interface SavedSearchData {
main$: DataMain$;
@ -132,12 +133,14 @@ export function getDataStateContainer({
services,
searchSessionManager,
getAppState,
getInternalState,
getSavedSearch,
setDataView,
}: {
services: DiscoverServices;
searchSessionManager: DiscoverSearchSessionManager;
getAppState: () => DiscoverAppState;
getInternalState: () => InternalState;
getSavedSearch: () => SavedSearch;
setDataView: (dataView: DataView) => void;
}): DiscoverDataStateContainer {
@ -213,6 +216,7 @@ export function getDataStateContainer({
searchSessionId,
services,
getAppState,
getInternalState,
savedSearch: getSavedSearch(),
useNewFieldsApi: !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE),
});

View file

@ -12,6 +12,7 @@ import {
ReduxLikeStateContainer,
} from '@kbn/kibana-utils-plugin/common';
import { DataView, DataViewListItem } from '@kbn/data-views-plugin/common';
import { Filter } from '@kbn/es-query';
import type { DataTableRecord } from '@kbn/discover-utils/types';
export interface InternalState {
@ -19,6 +20,7 @@ export interface InternalState {
savedDataViews: DataViewListItem[];
adHocDataViews: DataView[];
expandedDoc: DataTableRecord | undefined;
customFilters: Filter[];
}
interface InternalStateTransitions {
@ -35,6 +37,7 @@ interface InternalStateTransitions {
setExpandedDoc: (
state: InternalState
) => (dataView: DataTableRecord | undefined) => InternalState;
setCustomFilters: (state: InternalState) => (customFilters: Filter[]) => InternalState;
}
export type DiscoverInternalStateContainer = ReduxLikeStateContainer<
@ -52,6 +55,7 @@ export function getInternalStateContainer() {
adHocDataViews: [],
savedDataViews: [],
expandedDoc: undefined,
customFilters: [],
},
{
setDataView: (prevState: InternalState) => (nextDataView: DataView) => ({
@ -97,6 +101,10 @@ export function getInternalStateContainer() {
...prevState,
expandedDoc,
}),
setCustomFilters: (prevState: InternalState) => (customFilters: Filter[]) => ({
...prevState,
customFilters,
}),
},
{},
{ freeze: (state) => state }

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { History } from 'history';
import {
createKbnUrlStateStorage,
IKbnUrlStateStorage,
StateContainer,
withNotifyOnErrors,
} from '@kbn/kibana-utils-plugin/public';
@ -97,6 +98,10 @@ export interface DiscoverStateContainer {
* State of saved search, the saved object of Discover
*/
savedSearchState: DiscoverSavedSearchContainer;
/**
* State of url, allows updating and subscribing to url changes
*/
stateStorage: IKbnUrlStateStorage;
/**
* Service for handling search sessions
*/
@ -252,6 +257,7 @@ export function getDiscoverStateContainer({
services,
searchSessionManager,
getAppState: appStateContainer.getState,
getInternalState: internalStateContainer.getState,
getSavedSearch: savedSearchContainer.getState,
setDataView,
});
@ -451,6 +457,7 @@ export function getDiscoverStateContainer({
internalState: internalStateContainer,
dataState: dataStateContainer,
savedSearchState: savedSearchContainer,
stateStorage,
searchSessionManager,
actions: {
initializeAndSync,

View file

@ -68,6 +68,13 @@ describe('test fetchAll', () => {
abortController: new AbortController(),
inspectorAdapters: { requests: new RequestAdapter() },
getAppState: () => ({}),
getInternalState: () => ({
dataView: undefined,
savedDataViews: [],
adHocDataViews: [],
expandedDoc: undefined,
customFilters: [],
}),
searchSessionId: '123',
initialFetchStatus: FetchStatus.UNINITIALIZED,
useNewFieldsApi: true,
@ -258,6 +265,13 @@ describe('test fetchAll', () => {
savedSearch: savedSearchMock,
services: discoverServiceMock,
getAppState: () => ({ query }),
getInternalState: () => ({
dataView: undefined,
savedDataViews: [],
adHocDataViews: [],
expandedDoc: undefined,
customFilters: [],
}),
};
fetchAll(subjects, false, deps);
await waitForNextTick();

View file

@ -26,10 +26,12 @@ import { FetchStatus } from '../../types';
import { DataMsg, RecordRawType, SavedSearchData } from '../services/discover_data_state_container';
import { DiscoverServices } from '../../../build_services';
import { fetchSql } from './fetch_sql';
import { InternalState } from '../services/discover_internal_state_container';
export interface FetchDeps {
abortController: AbortController;
getAppState: () => DiscoverAppState;
getInternalState: () => InternalState;
initialFetchStatus: FetchStatus;
inspectorAdapters: Adapters;
savedSearch: SavedSearch;
@ -50,7 +52,14 @@ export function fetchAll(
reset = false,
fetchDeps: FetchDeps
): Promise<void> {
const { initialFetchStatus, getAppState, services, inspectorAdapters, savedSearch } = fetchDeps;
const {
initialFetchStatus,
getAppState,
getInternalState,
services,
inspectorAdapters,
savedSearch,
} = fetchDeps;
const { data } = services;
const searchSource = savedSearch.searchSource.createChild();
@ -70,6 +79,7 @@ export function fetchAll(
dataView,
services,
sort: getAppState().sort as SortOrder[],
customFilters: getInternalState().customFilters,
});
}

View file

@ -12,6 +12,7 @@ import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { discoverServiceMock } from '../../../__mocks__/services';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { Filter } from '@kbn/es-query';
const getUiSettingsMock = (value: boolean) => {
return {
@ -27,6 +28,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock,
services: discoverServiceMock,
sort: [] as SortOrder[],
customFilters: [],
});
expect(searchSource.getField('fields')).toBe(undefined);
});
@ -38,6 +40,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock,
services: discoverServiceMock,
sort: [] as SortOrder[],
customFilters: [],
});
expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]);
expect(searchSource.getField('fieldsFromSource')).toBe(undefined);
@ -50,6 +53,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock,
services: discoverServiceMock,
sort: [] as SortOrder[],
customFilters: [],
});
expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' },
@ -64,8 +68,25 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock,
services: discoverServiceMock,
sort: [] as SortOrder[],
customFilters: [],
});
expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined);
expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined);
});
test('should properly update the search source with the given custom filters', async () => {
const searchSource = createSearchSourceMock({});
discoverServiceMock.uiSettings = getUiSettingsMock(false);
const filter = { meta: { index: 'foo', key: 'bar' } } as Filter;
updateVolatileSearchSource(searchSource, {
dataView: dataViewMock,
services: discoverServiceMock,
sort: [] as SortOrder[],
customFilters: [filter],
});
expect(searchSource.getField('filter')).toEqual([filter]);
});
});

View file

@ -8,6 +8,7 @@
import { ISearchSource } from '@kbn/data-plugin/public';
import { DataViewType, DataView } from '@kbn/data-views-plugin/public';
import { Filter } from '@kbn/es-query';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { SEARCH_FIELDS_FROM_SOURCE, SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils';
import { DiscoverServices } from '../../../build_services';
@ -22,10 +23,12 @@ export function updateVolatileSearchSource(
dataView,
services,
sort,
customFilters,
}: {
dataView: DataView;
services: DiscoverServices;
sort?: SortOrder[];
customFilters: Filter[];
}
) {
const { uiSettings, data } = services;
@ -37,11 +40,16 @@ export function updateVolatileSearchSource(
const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE);
searchSource.setField('trackTotalHits', true).setField('sort', usedSort);
let filters = [...customFilters];
if (dataView.type !== DataViewType.ROLLUP) {
// Set the date range filter fields from timeFilter using the absolute format. Search sessions requires that it be converted from a relative range
searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(dataView));
const timeFilter = data.query.timefilter.timefilter.createFilter(dataView);
filters = timeFilter ? [...filters, timeFilter] : filters;
}
searchSource.setField('filter', filters);
if (useNewFieldsApi) {
searchSource.removeField('fieldsFromSource');
const fields: Record<string, string> = { field: '*' };

View file

@ -13,5 +13,6 @@ import type { ComponentType, ReactElement } from 'react';
export interface SearchBarCustomization {
id: 'search_bar';
CustomDataViewPicker?: ComponentType;
PrependFilterBar?: ComponentType;
CustomSearchBar?: (props: TopNavMenuProps<AggregateQuery>) => ReactElement;
}

View file

@ -14,6 +14,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import { buildExistsFilter } from '@kbn/es-query';
import { EuiComboBox } from '@elastic/eui';
import { SearchBar, SearchBarProps } from '../search_bar';
import { setIndexPatterns } from '../services';
@ -454,6 +455,23 @@ storiesOf('SearchBar', module)
},
} as unknown as SearchBarProps)
)
.add('with prepended controls', () =>
wrapSearchBarInContext({
prependFilterBar: (
<EuiComboBox
placeholder="Select option"
options={[
{
label: 'Filter 1',
},
]}
fullWidth={false}
isClearable={true}
/>
),
showQueryInput: true,
} as SearchBarProps)
)
.add('without switch query language', () =>
wrapSearchBarInContext({
disableQueryLanguageSwitcher: true,

View file

@ -9,7 +9,7 @@
import { EuiFlexGroup, useEuiTheme } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import type { Filter } from '@kbn/es-query';
import React, { useRef } from 'react';
import React, { ReactNode, useRef } from 'react';
import { DataView } from '@kbn/data-views-plugin/public';
import FilterItems, { type FilterItemsProps } from './filter_item/filter_items';
@ -33,6 +33,10 @@ export interface Props {
* Disable all interactive actions
*/
isDisabled?: boolean;
/**
* Prepends custom filter controls to the search bar
*/
prepend?: ReactNode;
/** Array of suggestion abstraction that controls the render of the field */
suggestionsAbstraction?: SuggestionsAbstraction;
}
@ -52,6 +56,7 @@ const FilterBarUI = React.memo(function FilterBarUI(props: Props) {
alignItems="center"
tabIndex={-1}
>
{props.prepend}
<FilterItems
filters={props.filters!}
onFiltersUpdated={props.onFiltersUpdated}

View file

@ -252,6 +252,7 @@ export function createSearchBar({
isScreenshotMode={isScreenshotMode}
dataTestSubj={props.dataTestSubj}
filtersForSuggestions={props.filtersForSuggestions}
prependFilterBar={props.prependFilterBar}
/>
</core.i18n.Context>
</KibanaContextProvider>

View file

@ -60,6 +60,7 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> {
filters?: Filter[];
filtersForSuggestions?: Filter[];
hiddenFilterPanelOptions?: QueryBarMenuProps['hiddenPanelOptions'];
prependFilterBar?: React.ReactNode;
// Date picker
isRefreshPaused?: boolean;
refreshInterval?: number;
@ -548,6 +549,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
hiddenPanelOptions={this.props.hiddenFilterPanelOptions}
isDisabled={this.props.isDisabled}
data-test-subj="unifiedFilterBar"
prepend={this.props.prependFilterBar}
suggestionsAbstraction={this.props.suggestionsAbstraction}
/>
);

View file

@ -19,6 +19,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
const browser = getService('browser');
const dataGrid = getService('dataGrid');
const defaultSettings = { defaultIndex: 'logstash-*' };
describe('Customizations', () => {
@ -68,5 +69,33 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
expect(title).to.eql(expected.title);
expect(description).to.eql(expected.description);
});
it('Search bar Prepend Filters exists and should apply filter properly', async () => {
// Validate custom filters are present
await testSubjects.existOrFail('customPrependedFilter');
await testSubjects.click('customPrependedFilter');
await testSubjects.existOrFail('optionsList-control-selection-exists');
// Retrieve option list popover
const optionsListControl = await testSubjects.find('optionsList-control-popover');
const optionsItems = await optionsListControl.findAllByCssSelector(
'[data-test-subj*="optionsList-control-selection-"]'
);
// Retrieve second item in the options along with the count of documents
const item = optionsItems[1];
const countBadge = await item.findByCssSelector(
'[data-test-subj="optionsList-document-count-badge"]'
);
const documentsCount = parseInt(await countBadge.getVisibleText(), 10);
// Click the item to apply filter
await item.click();
await PageObjects.header.waitUntilLoadingHasFinished();
// Validate that filter is applied
const rows = await dataGrid.getDocTableRows();
await expect(documentsCount).to.eql(rows.length);
});
});
};

View file

@ -8,7 +8,7 @@
"server": true,
"browser": true,
"configPath": ["xpack", "discoverLogExplorer"],
"requiredPlugins": ["discover", "fleet", "kibanaReact", "kibanaUtils"],
"requiredPlugins": ["data", "dataViews", "discover", "fleet", "kibanaReact", "kibanaUtils", "controls", "embeddable"],
"optionalPlugins": [],
"requiredBundles": []
}

View file

@ -0,0 +1,59 @@
/*
* 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 React from 'react';
import { ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { Query } from '@kbn/es-query';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { useControlPanels } from '../hooks/use_control_panels';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
interface CustomDatasetFiltersProps {
logExplorerProfileStateService: LogExplorerProfileStateService;
data: DataPublicPluginStart;
}
const CustomDatasetFilters = ({
logExplorerProfileStateService,
data,
}: CustomDatasetFiltersProps) => {
const { getInitialInput, setControlGroupAPI, query, filters, timeRange } = useControlPanels(
logExplorerProfileStateService,
data
);
return (
<ControlGroupContainer>
<ControlGroupRenderer
ref={setControlGroupAPI}
getCreationOptions={getInitialInput}
query={query as Query}
filters={filters ?? []}
timeRange={timeRange}
/>
</ControlGroupContainer>
);
};
const ControlGroupContainer = euiStyled.div`
.controlGroup {
min-height: unset;
}
.euiFormLabel {
padding-top: 0;
padding-bottom: 0;
line-height: 32px !important;
}
.euiFormControlLayout {
height: 32px;
}
`;
// eslint-disable-next-line import/no-default-export
export default CustomDatasetFilters;

View file

@ -11,14 +11,14 @@ import { DatasetsProvider, useDatasetsContext } from '../hooks/use_datasets';
import { IntegrationsProvider, useIntegrationsContext } from '../hooks/use_integrations';
import { IDatasetsClient } from '../services/datasets';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { useLogExplorerProfile } from '../hooks/use_log_explorer_profile';
import { useDatasetSelection } from '../hooks/use_dataset_selection';
interface CustomDatasetSelectorProps {
logExplorerProfileStateService: LogExplorerProfileStateService;
}
export const CustomDatasetSelector = withProviders(({ logExplorerProfileStateService }) => {
const { datasetSelection, handleDatasetSelectionChange } = useLogExplorerProfile(
const { datasetSelection, handleDatasetSelectionChange } = useDatasetSelection(
logExplorerProfileStateService
);

View file

@ -5,19 +5,22 @@
* 2.0.
*/
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { CustomizationCallback } from '@kbn/discover-plugin/public';
import React from 'react';
import { dynamic } from '../utils/dynamic';
const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector'));
const LazyCustomDatasetFilters = dynamic(() => import('./custom_dataset_filters'));
interface CreateLogExplorerProfileCustomizationsDeps {
core: CoreStart;
data: DataPublicPluginStart;
}
export const createLogExplorerProfileCustomizations =
({ core }: CreateLogExplorerProfileCustomizationsDeps): CustomizationCallback =>
({ core, data }: CreateLogExplorerProfileCustomizationsDeps): CustomizationCallback =>
async ({ customizations, stateContainer }) => {
// Lazy load dependencies
const datasetServiceModuleLoadable = import('../services/datasets');
@ -44,6 +47,7 @@ export const createLogExplorerProfileCustomizations =
/**
* Replace the DataViewPicker with a custom `DatasetSelector` to pick integrations streams
* Prepend the search bar with custom filter control groups depending on the selected dataset
*/
customizations.set({
id: 'search_bar',
@ -53,6 +57,12 @@ export const createLogExplorerProfileCustomizations =
logExplorerProfileStateService={logExplorerProfileStateService}
/>
),
PrependFilterBar: () => (
<LazyCustomDatasetFilters
logExplorerProfileStateService={logExplorerProfileStateService}
data={data}
/>
),
});
/**

View file

@ -0,0 +1,57 @@
/*
* 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 { ControlGroupInput } from '@kbn/controls-plugin/common';
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Query, TimeRange } from '@kbn/es-query';
import { useQuerySubscriber } from '@kbn/unified-field-list';
import { useSelector } from '@xstate/react';
import { useCallback } from 'react';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
export const useControlPanels = (
logExplorerProfileStateService: LogExplorerProfileStateService,
data: DataPublicPluginStart
) => {
const { query, filters, fromDate, toDate } = useQuerySubscriber({ data });
const timeRange: TimeRange = { from: fromDate!, to: toDate! };
const controlPanels = useSelector(logExplorerProfileStateService, (state) => {
if (!('controlPanels' in state.context)) return;
return state.context.controlPanels;
});
const getInitialInput = useCallback(
async (initialInput: Partial<ControlGroupInput>) => {
const input: Partial<ControlGroupInput> = {
...initialInput,
viewMode: ViewMode.VIEW,
panels: controlPanels ?? initialInput.panels,
filters: filters ?? [],
query: query as Query,
timeRange: { from: fromDate!, to: toDate! },
};
return { initialInput: input };
},
[controlPanels, filters, fromDate, query, toDate]
);
const setControlGroupAPI = useCallback(
(controlGroupAPI: ControlGroupAPI) => {
logExplorerProfileStateService.send({
type: 'INITIALIZE_CONTROL_GROUP_API',
controlGroupAPI,
});
},
[logExplorerProfileStateService]
);
return { getInitialInput, setControlGroupAPI, query, filters, timeRange };
};

View file

@ -10,13 +10,12 @@ import { useCallback } from 'react';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { DatasetSelectionChange } from '../utils/dataset_selection';
export const useLogExplorerProfile = (
export const useDatasetSelection = (
logExplorerProfileStateService: LogExplorerProfileStateService
) => {
const datasetSelection = useSelector(
logExplorerProfileStateService,
(state) => state.context.datasetSelection
);
const datasetSelection = useSelector(logExplorerProfileStateService, (state) => {
return state.context.datasetSelection;
});
const handleDatasetSelectionChange: DatasetSelectionChange = useCallback(
(data) => {

View file

@ -28,10 +28,10 @@ export class DiscoverLogExplorerPlugin
public setup() {}
public start(core: CoreStart, plugins: DiscoverLogExplorerStartDeps) {
const { discover } = plugins;
const { discover, data } = plugins;
discover.registerCustomizationProfile(LOG_EXPLORER_PROFILE_ID, {
customize: createLogExplorerProfileCustomizations({ core }),
customize: createLogExplorerProfileCustomizations({ core, data }),
deepLinks: [getLogExplorerDeepLink({ isVisible: this.config.featureFlags.deepLinkVisible })],
});
}

View file

@ -6,8 +6,30 @@
*/
import { AllDatasetSelection } from '../../../utils/dataset_selection';
import { DefaultLogExplorerProfileState } from './types';
import { ControlPanels, DefaultLogExplorerProfileState } from './types';
export const DEFAULT_CONTEXT: DefaultLogExplorerProfileState = {
datasetSelection: AllDatasetSelection.create(),
};
export const CONTROL_PANELS_URL_KEY = 'controlPanels';
export const availableControlsPanels = {
NAMESPACE: 'data_stream.namespace',
};
export const controlPanelConfigs: ControlPanels = {
[availableControlsPanels.NAMESPACE]: {
order: 0,
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: availableControlsPanels.NAMESPACE,
fieldName: availableControlsPanels.NAMESPACE,
title: 'Namespace',
},
},
};
export const availableControlPanelFields = Object.values(availableControlsPanels);

View file

@ -7,7 +7,7 @@
import { IToasts } from '@kbn/core/public';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import { actions, createMachine, interpret, InterpreterFrom } from 'xstate';
import { actions, createMachine, interpret, InterpreterFrom, raise } from 'xstate';
import { isDatasetSelection } from '../../../utils/dataset_selection';
import { createAndSetDataView } from './data_view_service';
import { DEFAULT_CONTEXT } from './defaults';
@ -15,18 +15,25 @@ import {
createCreateDataViewFailedNotifier,
createDatasetSelectionRestoreFailedNotifier,
} from './notifications';
import type {
import {
ControlPanelRT,
LogExplorerProfileContext,
LogExplorerProfileEvent,
LogExplorerProfileTypestate,
LogExplorerProfileTypeState,
} from './types';
import { initializeFromUrl, listenUrlChange } from './url_state_storage_service';
import {
initializeControlPanels,
initializeFromUrl,
listenUrlChange,
subscribeControlGroup,
updateControlPanels,
} from './url_state_storage_service';
export const createPureLogExplorerProfileStateMachine = (
initialContext: LogExplorerProfileContext
) =>
/** @xstate-layout N4IgpgJg5mDOIC5QBkD2UCiAPADgG1QCcxCAFQ1AMwEs8wA6AVwDtrWAXagQz2oC9IAYgDaABgC6iUDlSxqnVMykgsiALQAmAMz0AHAE5dAFi0B2AKwAaEAE9EARi3n6+gGxHjZ8wF9v1tJi4BMRkFDR09Gzy3Lx8bFAAYhQAtgCqhHiCEIoMbABuqADWDAHY+EQk5FS0uRwx-PFJqGkZCPmoAMZcCsxi4n3KMnI9yqoI9qai9KZaokauuhbWdggaGrrT5vqmruauGj5+IKVBFaHVEVGcPA3MiSnpmSQUhPT43ZREyfQn5SFV4Vq0RucTuTRaeDazAKXR6fQGSBAQ2iilG6nsGns0127lEB0WBlErmWiDWGws2xxB18-nQZWClTCNUidRB8QAIt0uAA1ahgADuWRyLIKxR+dNO-yZl1ZsQ5XN5AqhMO61EU8Ikg1kKKUiLGjlM9HMGlcRIxogsWi0JIQ82cpnsunMxn0JtEWnWNOOEr+jIuQOucrunPYPL5gueRDeeA+X3FgV950BLOBQagIbDSvasLVvQkCOk2pGesQzjM9lEGN0WkWWlchl0NvM9n09Hsew0+n0ojmztdXt+DKTzKu9QEEEiEDoglSpHZAEEACoYAD6C8X84AyhhFyvt8gMABhRcASQA8gA5AtIou5tEIetTesY5vmC0mfTW2yILz0Vz2fZXEfdxdHbIwBx9IcARHWV+EgSdp3XLcdz3DAD2Pc8LxXDAACUcLPHDr2RYtQDGBZWzWXFTAsUwPy-FYjDceh3XdTsjCJDwwIghMoOlAMx3gxgcAgVVgwVcMhWYWpRRKSCzmgmVUzgichJEzgxNDRV+WVTpVXVfNNURYi7xLBAHQ2f8rS2UR9A-QwbU7Q0PDtD1tH2bQtG4+l5L4lNA2UphhNE9NxIFQRI1ed52E+QhvkHHz-T8gSVKC9SQs08MdJzfT+kMwthhM0jEEmLFtFEcw7VsrQu0bb9xkMFxu20EwLB7HtPK9ZhUAgOBlHiqV-S1ArUVMzR1jbcyljq7R6HmZyvC8yU-WTFhRxBSAhp1e81EdKYJkdKaVgxDQXAWo5+uWmClNBe5mkeTaSJURBP2Yt9TFAg4bXsCsXBAlrDlpHiEuTNa0wzLSHsKp6EFA6YtH+m06ymOYjA0SZqytK0TUWxMFP49aIEhkaioQGsNnMLQK3htYyUpr7vtmpr4a8Nr3Rx3jEtBgLqCnMAid1EmTFcI1KfdVGaerexEdNeg0crGZ2xA9t2eBq7-PHQK1PlDKBX5+8KzmZjqO2PFzFo6r6J-IC-1cSmjBbDjQIq3xfCAA */
createMachine<LogExplorerProfileContext, LogExplorerProfileEvent, LogExplorerProfileTypestate>(
/** @xstate-layout N4IgpgJg5mDOIC5QBkD2UCiAPADgG1QCcxCAFQ1AMwEs8wA6AVwDtrWAXagQz2oC9IAYgDaABgC6iUDlSxqnVMykgsiAIwAOAMz0NorVoDsAVi0a1x0QE4ATADYANCACe6zfVMbjeu-rWi7DQAWAF8QpzRMXAJiMgoaOno2eW5ePjYoADEKAFsAVUI8QQhFBjYAN1QAawZI7HwiEnIqWjKOVP4M7NR8woQK1ABjLgVmMXFx5Rk5UeVVBEsNekMrQK0bYKC7fztHF3VDII8vDTXDNUNtu2MwiPR6mKb41qT2nk7mLNyCopIKQno+BGlCIOXodWijTiLUSyU473Sn26vTw-WYlWGo3GkyQIGmKUUc0Qi2Wq20GyCWx2e1cCHM9CCXmuekMokO2y0txAEIasWaCTaKQRGQAIiMuAA1ahgADuxVKr0qNXB90hfOesLeaVF4qlsrRGJG1EU2IkU1kBKUuPmdhsTlpxjUR00Nis+g0NjUVis3i5PMe0IFryF2s+YvYkulcr+REBeGBoJVUV5Txhgvhoag4cj+oGmONYwkOOkFtm1uJdv2CDUFzsHkOjpshhsWguahsftVKcDLzhHURUAAwop2BQ8KQuMwwHhYPKp4rqrUuwH+b2tR8hyOxxOpzODUMjSai2bcfiy6B5qZjAyjIzDOYbNYm-bEFYDPRrJZNJpvI+NJ3kxXDV037DJh2YUdUHHSdp1nGMASBdgQUIMF-ShVdNRDDdwMg6Dd1gfd8yPCYTxLGYCyJBYtGvIJbw0e92yfQwXwQIJvXoN9jCsIIAiMV1XQAh50OA4MM34SB6AgcVYDAdgAGVpzAQZRiSCA6EEPJSBFABBAAVDAAH0dN07S5IwXSDLM5AMEHXSAEkAHkADlizxUsKPLatrCOZt720LRRFdIIbErWlmzUZZjEMaLNHMbRzkEtVUyDPsEQkqSIxk+TFOUgtVPU4zTPMyyMGs2zHKcgyACUMDk3SHJqgzMm0uzkDyGrXLPDyL3UbzlibDR-MC7iQpY8xDF0LjqNsIx6LURLuwwkC0ogSTpNkhS6FyxQmBwDKdQjPU5RKecBmVND1TTUT+3S9bsq2lTGD2o0w11KNCMPQsSMkU93MJTz21Megm18QJLnbRkaVfNQdFWURggMKKtnvBagKu1K0luzKNpyx7ns4V7DqjQR4LjBMUKTITLpS9cBFWjKuCyzalLx-bCZzGUPqxY8frIy1KPbdxLC0WxaLY8WghY20lmuBtRB41tHw7cJuWXYT0dpiTBi3KCAHEKCe2AmFYTWIEEOynLs+ztOQOyAC1DMHZzdKqhzkAM3XXc0gztNIOzOr+q0eurQGPEZCwBqbIJLhY-xWQ4rRGUpa5PXWVH1ZprC6fobWILHfXUEN-KwA0rS9IwEUjL07SDIlOyMAAdQD8j-uDmHLg4wLG3sN0rCdWPqKWGtKW9Aw+9tdPqbXLOtZ1vAC6L6g1JLzTjMd53Xfd0htKc0q5Ob-mAYMHRtiCYxAlOMwgn7qsazsCb9DsWjH3htRdkn5Lp7E7Pc9whecCNk9Nmm485QR3LBOcbQlRLkAhnL+N1Vq-3zgbABu1gE4W3DBPceZPqml5m5FuQcVBuBsNeYw4djCR0ODHW+noJqUN8GfeGoh9AGA-j2TC39Z6gPnigwB+MwJz3ATOEmhB-hkyQomC6n9OEIJznPf+-D0FCKwQRHB3NvrmkIQLUO5DzCUPvFHGhtIYb+HoLaOwWhdjGG8CYfQYQVbMFQBAOAyhpEcLAFow+wcAC0UMEA+OvN6YJITQmchVu4paxsMbiQgF488xDWKhTcPQow3Fz4rBhi2dhUSYkDmRD8eJ3VEkDV0IcKw4VAotkCCxbiH5r7aECI6YI2hQgRLVlPWRwp2ZHSKa3RJKwrAeD7jWfwidgqrFqUceijpWQ-m-PfHJIk8mCJ4cI+Av1tGeXsDLXwFhyGGGoosSWVZGRDMCByYKVwmxWCWRrGecTNneIGZSegFg7H8SbCYfxZ8T6nBhlct+Ny7B3Mzlw+md1mbbSIV1fp8wVg2DeVFSwnzornxYuQiKbYEUGBbIcUF8CVprWxvdFmeUl50D6UQ+YwRRBIo+bYL56KqwpzeTWUhpg-zUXCXcWBnTlqYwhSSqFrMXpZjerKKlAsthDLfNsUWdgKnXysCxQ5EVtjeFMOPDJbTeVUxkQK2J8ieGKKlZ5BF9KUWMrRf4qw2gGRsU0MyGGXhbntL5Qa66RKkF6z4dE02Zrg4+iWIcRk6xvQsI0G+WOfUbA8W0BcEaFhdWqw9R4r1grjV-z9RSzxTyEnzDfmY9JrZmyWPPtHWOnprzaFMEClOrSCVdMzT63hhdUFALFRgsBqjA2JNpZa4aIUbWxzfkMzQjIPTNjjefZWYQgA */
createMachine<LogExplorerProfileContext, LogExplorerProfileEvent, LogExplorerProfileTypeState>(
{
context: initialContext,
predictableActionArguments: true,
@ -53,7 +60,7 @@ export const createPureLogExplorerProfileStateMachine = (
invoke: {
src: 'createDataView',
onDone: {
target: 'initialized',
target: 'initializingControlPanels',
},
onError: {
target: 'initialized',
@ -61,33 +68,90 @@ export const createPureLogExplorerProfileStateMachine = (
},
},
},
initializingControlPanels: {
invoke: {
src: 'initializeControlPanels',
onDone: {
target: 'initialized',
actions: ['storeControlPanels'],
},
onError: {
target: 'initialized',
},
},
},
initialized: {
initial: 'idle',
type: 'parallel',
states: {
idle: {
invoke: {
src: 'listenUrlChange',
},
on: {
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
datasetSelection: {
initial: 'idle',
states: {
idle: {
invoke: {
src: 'listenUrlChange',
},
on: {
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
updatingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'idle',
actions: ['notifyDataViewUpdate'],
},
onError: {
target: 'idle',
actions: ['notifyCreateDataViewFailed'],
},
},
},
},
},
updatingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'idle',
controlGroups: {
initial: 'uninitialized',
states: {
uninitialized: {
on: {
INITIALIZE_CONTROL_GROUP_API: {
target: 'idle',
cond: 'controlGroupAPIExists',
actions: ['storeControlGroupAPI'],
},
},
},
onError: {
target: 'idle',
actions: ['notifyCreateDataViewFailed'],
idle: {
invoke: {
src: 'subscribeControlGroup',
},
on: {
DATA_VIEW_UPDATED: {
target: 'updatingControlPanels',
},
UPDATE_CONTROL_PANELS: {
target: 'updatingControlPanels',
},
},
},
updatingControlPanels: {
invoke: {
src: 'updateControlPanels',
onDone: {
target: 'idle',
actions: ['storeControlPanels'],
},
onError: {
target: 'idle',
},
},
},
},
},
@ -104,6 +168,26 @@ export const createPureLogExplorerProfileStateMachine = (
}
: {}
),
storeControlGroupAPI: actions.assign((_context, event) =>
'controlGroupAPI' in event
? {
controlGroupAPI: event.controlGroupAPI,
}
: {}
),
storeControlPanels: actions.assign((_context, event) =>
'data' in event && ControlPanelRT.is(event.data)
? {
controlPanels: event.data,
}
: {}
),
notifyDataViewUpdate: raise('DATA_VIEW_UPDATED'),
},
guards: {
controlGroupAPIExists: (_context, event) => {
return 'controlGroupAPI' in event && event.controlGroupAPI != null;
},
},
}
);
@ -127,7 +211,10 @@ export const createLogExplorerProfileStateMachine = ({
services: {
createDataView: createAndSetDataView({ stateContainer }),
initializeFromUrl: initializeFromUrl({ stateContainer }),
initializeControlPanels: initializeControlPanels({ stateContainer }),
listenUrlChange: listenUrlChange({ stateContainer }),
subscribeControlGroup: subscribeControlGroup({ stateContainer }),
updateControlPanels: updateControlPanels({ stateContainer }),
},
});

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import * as rt from 'io-ts';
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { DoneInvokeEvent } from 'xstate';
import type { DatasetEncodingError, DatasetSelection } from '../../../utils/dataset_selection';
@ -12,9 +14,17 @@ export interface WithDatasetSelection {
datasetSelection: DatasetSelection;
}
export interface WithControlPanelGroupAPI {
controlGroupAPI: ControlGroupAPI;
}
export interface WithControlPanels {
controlPanels: ControlPanels;
}
export type DefaultLogExplorerProfileState = WithDatasetSelection;
export type LogExplorerProfileTypestate =
export type LogExplorerProfileTypeState =
| {
value: 'uninitialized';
context: WithDatasetSelection;
@ -28,21 +38,37 @@ export type LogExplorerProfileTypestate =
context: WithDatasetSelection;
}
| {
value: 'initialized';
value: 'initializingControlPanels';
context: WithDatasetSelection;
}
| {
value: 'initialized';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.idle';
context: WithDatasetSelection;
context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
}
| {
value: 'initialized.updatingDataView';
context: WithDatasetSelection;
}
| {
value: 'initialized.controlGroups.uninitialized';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.controlGroups.idle';
context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
}
| {
value: 'initialized.controlGroups.updatingControlPanels';
context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
};
export type LogExplorerProfileContext = LogExplorerProfileTypestate['context'];
export type LogExplorerProfileContext = LogExplorerProfileTypeState['context'];
export type LogExplorerProfileStateValue = LogExplorerProfileTypestate['value'];
export type LogExplorerProfileStateValue = LogExplorerProfileTypeState['value'];
export type LogExplorerProfileEvent =
| {
@ -52,5 +78,36 @@ export type LogExplorerProfileEvent =
| {
type: 'DATASET_SELECTION_RESTORE_FAILURE';
}
| {
type: 'INITIALIZE_CONTROL_GROUP_API';
controlGroupAPI: ControlGroupAPI | undefined;
}
| {
type: 'UPDATE_CONTROL_PANELS';
controlPanels: ControlPanels | null;
}
| DoneInvokeEvent<DatasetSelection>
| DoneInvokeEvent<ControlPanels>
| DoneInvokeEvent<ControlGroupAPI>
| DoneInvokeEvent<DatasetEncodingError>
| DoneInvokeEvent<Error>;
const PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]),
});
export const ControlPanelRT = rt.record(rt.string, PanelRT);
export type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;

View file

@ -5,14 +5,27 @@
* 2.0.
*/
import { InvokeCreator } from 'xstate';
import { pick, mapValues } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
AllDatasetSelection,
decodeDatasetSelectionId,
hydrateDatasetSelection,
isDatasetSelection,
} from '../../../utils/dataset_selection';
import type { LogExplorerProfileContext, LogExplorerProfileEvent } from './types';
import {
ControlPanelRT,
ControlPanels,
LogExplorerProfileContext,
LogExplorerProfileEvent,
} from './types';
import {
availableControlPanelFields,
controlPanelConfigs,
CONTROL_PANELS_URL_KEY,
} from './defaults';
interface LogExplorerProfileUrlStateDependencies {
stateContainer: DiscoverStateContainer;
@ -61,6 +74,20 @@ export const initializeFromUrl =
return extractDatasetSelectionFromIndex({ index, context });
};
export const initializeControlPanels =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async (context) => {
const urlPanels = stateContainer.stateStorage.get<ControlPanels>(CONTROL_PANELS_URL_KEY);
const controlPanelsWithId = constructControlPanelsWithDataViewId(stateContainer, urlPanels!);
return controlPanelsWithId;
};
const extractDatasetSelectionFromIndex = ({
index,
context,
@ -78,3 +105,134 @@ const extractDatasetSelectionFromIndex = ({
return datasetSelection;
};
export const subscribeControlGroup =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
(context) =>
(send) => {
if (!('controlGroupAPI' in context)) return;
const filtersSubscription = context.controlGroupAPI.onFiltersPublished$.subscribe(
(newFilters) => {
stateContainer.internalState.transitions.setCustomFilters(newFilters);
stateContainer.actions.fetchData();
}
);
// Keeps our state in sync with the url changes and makes sure it adheres to correct schema
const urlSubscription = stateContainer.stateStorage
.change$<ControlPanels>(CONTROL_PANELS_URL_KEY)
.subscribe((controlPanels) => {
if (!deepEqual(controlPanels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels });
}
});
// Keeps the url in sync with the controls state after change
const inputSubscription = context.controlGroupAPI.getInput$().subscribe(({ panels }) => {
if (!deepEqual(panels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels: panels });
}
});
return () => {
filtersSubscription.unsubscribe();
urlSubscription.unsubscribe();
inputSubscription.unsubscribe();
};
};
export const updateControlPanels =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async (context, event) => {
if (!('controlGroupAPI' in context)) return;
const newControlPanels =
('controlPanels' in event && event.controlPanels) || context.controlPanels;
const controlPanelsWithId = constructControlPanelsWithDataViewId(
stateContainer,
newControlPanels!
);
context.controlGroupAPI.updateInput({ panels: controlPanelsWithId });
return controlPanelsWithId;
};
/**
* Utils
*/
const constructControlPanelsWithDataViewId = (
stateContainer: DiscoverStateContainer,
newControlPanels: ControlPanels
) => {
const dataView = stateContainer.internalState.getState().dataView!;
const validatedControlPanels = isValidState(newControlPanels)
? newControlPanels
: getVisibleControlPanelsConfig(dataView);
const controlsPanelsWithId = mergeDefaultPanelsWithUrlConfig(dataView, validatedControlPanels!);
stateContainer.stateStorage.set(CONTROL_PANELS_URL_KEY, cleanControlPanels(controlsPanelsWithId));
return controlsPanelsWithId;
};
const isValidState = (state: ControlPanels | undefined | null): boolean => {
return Object.keys(state ?? {}).length > 0 && ControlPanelRT.is(state);
};
const getVisibleControlPanels = (dataView: DataView | undefined) =>
availableControlPanelFields.filter(
(panelKey) => dataView?.fields.getByName(panelKey) !== undefined
);
export const getVisibleControlPanelsConfig = (dataView?: DataView) => {
return getVisibleControlPanels(dataView).reduce((panelsMap, panelKey) => {
const config = controlPanelConfigs[panelKey];
return { ...panelsMap, [panelKey]: config };
}, {} as ControlPanels);
};
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
return mapValues(controlPanels, (controlPanelConfig) => ({
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
}));
};
const cleanControlPanels = (controlPanels: ControlPanels) => {
return mapValues(controlPanels, (controlPanelConfig) => {
const { explicitInput } = controlPanelConfig;
const { dataViewId, ...rest } = explicitInput;
return { ...controlPanelConfig, explicitInput: rest };
});
};
const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlPanels) => {
// Get default panel configs from existing fields in data view
const visiblePanels = getVisibleControlPanelsConfig(dataView);
// Get list of panel which can be overridden to avoid merging additional config from url
const existingKeys = Object.keys(visiblePanels);
const controlPanelsToOverride = pick(urlPanels, existingKeys);
// Merge default and existing configs and add dataView.id to each of them
return addDataViewIdToControlPanels(
{ ...visiblePanels, ...controlPanelsToOverride },
dataView.id
);
};

View file

@ -4,11 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DiscoverStart } from '@kbn/discover-plugin/public';
export type DiscoverLogExplorerPluginSetup = void;
export type DiscoverLogExplorerPluginStart = void;
export interface DiscoverLogExplorerStartDeps {
data: DataPublicPluginStart;
discover: DiscoverStart;
}

View file

@ -13,6 +13,12 @@
"@kbn/io-ts-utils",
"@kbn/data-views-plugin",
"@kbn/rison",
"@kbn/controls-plugin",
"@kbn/embeddable-plugin",
"@kbn/es-query",
"@kbn/kibana-react-plugin",
"@kbn/data-plugin",
"@kbn/unified-field-list",
"@kbn/config-schema",
],
"exclude": ["target/**/*"]

View file

@ -61,7 +61,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const azureActivitylogsIndex =
'BQZwpgNmDGAuCWB7AdgLmAEwIay+W6yWAtmKgOQSIDmIAtFgF4CuATmAHRZzwBu8sAJ5VadAFTkANAlhRU3BPyEiQASklFS8lu2kC55AII6wAAgAyNEFN5hWIJGnIBGDgFYOAJgDM5deCgeFAAVQQAHMgdkaihVIA===';
await PageObjects.common.navigateToApp('discover', {
hash: `/p/log-explorer?_a=(index:${encodeURIComponent(azureActivitylogsIndex)})`,
hash: `/p/log-explorer?_a=(index:${encodeURIComponent(
azureActivitylogsIndex
)})&controlPanels=()`,
});
const azureDatasetSelectionTitle =
await PageObjects.discoverLogExplorer.getDatasetSelectorButtonText();

View file

@ -61,7 +61,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const azureActivitylogsIndex =
'BQZwpgNmDGAuCWB7AdgLmAEwIay+W6yWAtmKgOQSIDmIAtFgF4CuATmAHRZzwBu8sAJ5VadAFTkANAlhRU3BPyEiQASklFS8lu2kC55AII6wAAgAyNEFN5hWIJGnIBGDgFYOAJgDM5deCgeFAAVQQAHMgdkaihVIA===';
await PageObjects.common.navigateToApp('discover', {
hash: `/p/log-explorer?_a=(index:${encodeURIComponent(azureActivitylogsIndex)})`,
hash: `/p/log-explorer?_a=(index:${encodeURIComponent(
azureActivitylogsIndex
)})&controlPanels=()`,
});
const azureDatasetSelectionTitle =
await PageObjects.discoverLogExplorer.getDatasetSelectorButtonText();