[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", "id": "discoverCustomizationExamples",
"server": false, "server": false,
"browser": true, "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 React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable'; 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'; import image from './discover_customization_examples.png';
export interface DiscoverCustomizationExamplesSetupPlugins { 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 () => { return () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('Cleaning up Logs explorer customizations'); console.log('Cleaning up Logs explorer customizations');

View file

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

View file

@ -144,4 +144,10 @@ export const optionsListReducers = {
) => { ) => {
state.output.dataViewId = action.payload; 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 * Request params
*/ */
const { query, filters } = useQuerySubscriber({ data: services.data }); const { query, filters } = useQuerySubscriber({ data: services.data });
const customFilters = useInternalStateSelector((state) => state.customFilters);
const timefilter = services.data.query.timefilter.timefilter; const timefilter = services.data.query.timefilter.timefilter;
const timeRange = timefilter.getAbsoluteTime(); const timeRange = timefilter.getAbsoluteTime();
const relativeTimeRange = useObservable( const relativeTimeRange = useObservable(
@ -305,7 +306,7 @@ export const useDiscoverHistogram = ({
services: { ...services, uiActions: getUiActions() }, services: { ...services, uiActions: getUiActions() },
dataView: isPlainRecord ? textBasedDataView : dataView, dataView: isPlainRecord ? textBasedDataView : dataView,
query: isPlainRecord ? textBasedQuery : query, query: isPlainRecord ? textBasedQuery : query,
filters, filters: [...(filters ?? []), ...customFilters],
timeRange, timeRange,
relativeTimeRange, relativeTimeRange,
columns, columns,

View file

@ -234,6 +234,11 @@ export const DiscoverTopNav = ({
textBasedLanguageModeErrors ? [textBasedLanguageModeErrors] : undefined textBasedLanguageModeErrors ? [textBasedLanguageModeErrors] : undefined
} }
onTextBasedSavedAndExit={onTextBasedSavedAndExit} 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 { fetchAll } from '../utils/fetch_all';
import { sendResetMsg } from '../hooks/use_saved_search_messages'; import { sendResetMsg } from '../hooks/use_saved_search_messages';
import { getFetch$ } from '../utils/get_fetch_observable'; import { getFetch$ } from '../utils/get_fetch_observable';
import { InternalState } from './discover_internal_state_container';
export interface SavedSearchData { export interface SavedSearchData {
main$: DataMain$; main$: DataMain$;
@ -132,12 +133,14 @@ export function getDataStateContainer({
services, services,
searchSessionManager, searchSessionManager,
getAppState, getAppState,
getInternalState,
getSavedSearch, getSavedSearch,
setDataView, setDataView,
}: { }: {
services: DiscoverServices; services: DiscoverServices;
searchSessionManager: DiscoverSearchSessionManager; searchSessionManager: DiscoverSearchSessionManager;
getAppState: () => DiscoverAppState; getAppState: () => DiscoverAppState;
getInternalState: () => InternalState;
getSavedSearch: () => SavedSearch; getSavedSearch: () => SavedSearch;
setDataView: (dataView: DataView) => void; setDataView: (dataView: DataView) => void;
}): DiscoverDataStateContainer { }): DiscoverDataStateContainer {
@ -213,6 +216,7 @@ export function getDataStateContainer({
searchSessionId, searchSessionId,
services, services,
getAppState, getAppState,
getInternalState,
savedSearch: getSavedSearch(), savedSearch: getSavedSearch(),
useNewFieldsApi: !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), useNewFieldsApi: !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE),
}); });

View file

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

View file

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

View file

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

View file

@ -26,10 +26,12 @@ import { FetchStatus } from '../../types';
import { DataMsg, RecordRawType, SavedSearchData } from '../services/discover_data_state_container'; import { DataMsg, RecordRawType, SavedSearchData } from '../services/discover_data_state_container';
import { DiscoverServices } from '../../../build_services'; import { DiscoverServices } from '../../../build_services';
import { fetchSql } from './fetch_sql'; import { fetchSql } from './fetch_sql';
import { InternalState } from '../services/discover_internal_state_container';
export interface FetchDeps { export interface FetchDeps {
abortController: AbortController; abortController: AbortController;
getAppState: () => DiscoverAppState; getAppState: () => DiscoverAppState;
getInternalState: () => InternalState;
initialFetchStatus: FetchStatus; initialFetchStatus: FetchStatus;
inspectorAdapters: Adapters; inspectorAdapters: Adapters;
savedSearch: SavedSearch; savedSearch: SavedSearch;
@ -50,7 +52,14 @@ export function fetchAll(
reset = false, reset = false,
fetchDeps: FetchDeps fetchDeps: FetchDeps
): Promise<void> { ): Promise<void> {
const { initialFetchStatus, getAppState, services, inspectorAdapters, savedSearch } = fetchDeps; const {
initialFetchStatus,
getAppState,
getInternalState,
services,
inspectorAdapters,
savedSearch,
} = fetchDeps;
const { data } = services; const { data } = services;
const searchSource = savedSearch.searchSource.createChild(); const searchSource = savedSearch.searchSource.createChild();
@ -70,6 +79,7 @@ export function fetchAll(
dataView, dataView,
services, services,
sort: getAppState().sort as SortOrder[], 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 type { SortOrder } from '@kbn/saved-search-plugin/public';
import { discoverServiceMock } from '../../../__mocks__/services'; import { discoverServiceMock } from '../../../__mocks__/services';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { Filter } from '@kbn/es-query';
const getUiSettingsMock = (value: boolean) => { const getUiSettingsMock = (value: boolean) => {
return { return {
@ -27,6 +28,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock, dataView: dataViewMock,
services: discoverServiceMock, services: discoverServiceMock,
sort: [] as SortOrder[], sort: [] as SortOrder[],
customFilters: [],
}); });
expect(searchSource.getField('fields')).toBe(undefined); expect(searchSource.getField('fields')).toBe(undefined);
}); });
@ -38,6 +40,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock, dataView: dataViewMock,
services: discoverServiceMock, services: discoverServiceMock,
sort: [] as SortOrder[], sort: [] as SortOrder[],
customFilters: [],
}); });
expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]); expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]);
expect(searchSource.getField('fieldsFromSource')).toBe(undefined); expect(searchSource.getField('fieldsFromSource')).toBe(undefined);
@ -50,6 +53,7 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock, dataView: dataViewMock,
services: discoverServiceMock, services: discoverServiceMock,
sort: [] as SortOrder[], sort: [] as SortOrder[],
customFilters: [],
}); });
expect(volatileSearchSourceMock.getField('fields')).toEqual([ expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' }, { field: '*', include_unmapped: 'true' },
@ -64,8 +68,25 @@ describe('updateVolatileSearchSource', () => {
dataView: dataViewMock, dataView: dataViewMock,
services: discoverServiceMock, services: discoverServiceMock,
sort: [] as SortOrder[], sort: [] as SortOrder[],
customFilters: [],
}); });
expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined); expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined);
expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(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 { ISearchSource } from '@kbn/data-plugin/public';
import { DataViewType, DataView } from '@kbn/data-views-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 type { SortOrder } from '@kbn/saved-search-plugin/public';
import { SEARCH_FIELDS_FROM_SOURCE, SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils'; import { SEARCH_FIELDS_FROM_SOURCE, SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils';
import { DiscoverServices } from '../../../build_services'; import { DiscoverServices } from '../../../build_services';
@ -22,10 +23,12 @@ export function updateVolatileSearchSource(
dataView, dataView,
services, services,
sort, sort,
customFilters,
}: { }: {
dataView: DataView; dataView: DataView;
services: DiscoverServices; services: DiscoverServices;
sort?: SortOrder[]; sort?: SortOrder[];
customFilters: Filter[];
} }
) { ) {
const { uiSettings, data } = services; const { uiSettings, data } = services;
@ -37,11 +40,16 @@ export function updateVolatileSearchSource(
const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE);
searchSource.setField('trackTotalHits', true).setField('sort', usedSort); searchSource.setField('trackTotalHits', true).setField('sort', usedSort);
let filters = [...customFilters];
if (dataView.type !== DataViewType.ROLLUP) { 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 // 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) { if (useNewFieldsApi) {
searchSource.removeField('fieldsFromSource'); searchSource.removeField('fieldsFromSource');
const fields: Record<string, string> = { field: '*' }; const fields: Record<string, string> = { field: '*' };

View file

@ -13,5 +13,6 @@ import type { ComponentType, ReactElement } from 'react';
export interface SearchBarCustomization { export interface SearchBarCustomization {
id: 'search_bar'; id: 'search_bar';
CustomDataViewPicker?: ComponentType; CustomDataViewPicker?: ComponentType;
PrependFilterBar?: ComponentType;
CustomSearchBar?: (props: TopNavMenuProps<AggregateQuery>) => ReactElement; 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import { buildExistsFilter } from '@kbn/es-query'; import { buildExistsFilter } from '@kbn/es-query';
import { EuiComboBox } from '@elastic/eui';
import { SearchBar, SearchBarProps } from '../search_bar'; import { SearchBar, SearchBarProps } from '../search_bar';
import { setIndexPatterns } from '../services'; import { setIndexPatterns } from '../services';
@ -454,6 +455,23 @@ storiesOf('SearchBar', module)
}, },
} as unknown as SearchBarProps) } 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', () => .add('without switch query language', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
disableQueryLanguageSwitcher: true, disableQueryLanguageSwitcher: true,

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
const kibanaServer = getService('kibanaServer'); const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects'); const testSubjects = getService('testSubjects');
const browser = getService('browser'); const browser = getService('browser');
const dataGrid = getService('dataGrid');
const defaultSettings = { defaultIndex: 'logstash-*' }; const defaultSettings = { defaultIndex: 'logstash-*' };
describe('Customizations', () => { describe('Customizations', () => {
@ -68,5 +69,33 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
expect(title).to.eql(expected.title); expect(title).to.eql(expected.title);
expect(description).to.eql(expected.description); 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, "server": true,
"browser": true, "browser": true,
"configPath": ["xpack", "discoverLogExplorer"], "configPath": ["xpack", "discoverLogExplorer"],
"requiredPlugins": ["discover", "fleet", "kibanaReact", "kibanaUtils"], "requiredPlugins": ["data", "dataViews", "discover", "fleet", "kibanaReact", "kibanaUtils", "controls", "embeddable"],
"optionalPlugins": [], "optionalPlugins": [],
"requiredBundles": [] "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 { IntegrationsProvider, useIntegrationsContext } from '../hooks/use_integrations';
import { IDatasetsClient } from '../services/datasets'; import { IDatasetsClient } from '../services/datasets';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile'; 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 { interface CustomDatasetSelectorProps {
logExplorerProfileStateService: LogExplorerProfileStateService; logExplorerProfileStateService: LogExplorerProfileStateService;
} }
export const CustomDatasetSelector = withProviders(({ logExplorerProfileStateService }) => { export const CustomDatasetSelector = withProviders(({ logExplorerProfileStateService }) => {
const { datasetSelection, handleDatasetSelectionChange } = useLogExplorerProfile( const { datasetSelection, handleDatasetSelectionChange } = useDatasetSelection(
logExplorerProfileStateService logExplorerProfileStateService
); );

View file

@ -5,19 +5,22 @@
* 2.0. * 2.0.
*/ */
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { CoreStart } from '@kbn/core/public'; import type { CoreStart } from '@kbn/core/public';
import { CustomizationCallback } from '@kbn/discover-plugin/public'; import { CustomizationCallback } from '@kbn/discover-plugin/public';
import React from 'react'; import React from 'react';
import { dynamic } from '../utils/dynamic'; import { dynamic } from '../utils/dynamic';
const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector')); const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector'));
const LazyCustomDatasetFilters = dynamic(() => import('./custom_dataset_filters'));
interface CreateLogExplorerProfileCustomizationsDeps { interface CreateLogExplorerProfileCustomizationsDeps {
core: CoreStart; core: CoreStart;
data: DataPublicPluginStart;
} }
export const createLogExplorerProfileCustomizations = export const createLogExplorerProfileCustomizations =
({ core }: CreateLogExplorerProfileCustomizationsDeps): CustomizationCallback => ({ core, data }: CreateLogExplorerProfileCustomizationsDeps): CustomizationCallback =>
async ({ customizations, stateContainer }) => { async ({ customizations, stateContainer }) => {
// Lazy load dependencies // Lazy load dependencies
const datasetServiceModuleLoadable = import('../services/datasets'); const datasetServiceModuleLoadable = import('../services/datasets');
@ -44,6 +47,7 @@ export const createLogExplorerProfileCustomizations =
/** /**
* Replace the DataViewPicker with a custom `DatasetSelector` to pick integrations streams * 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({ customizations.set({
id: 'search_bar', id: 'search_bar',
@ -53,6 +57,12 @@ export const createLogExplorerProfileCustomizations =
logExplorerProfileStateService={logExplorerProfileStateService} 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 { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { DatasetSelectionChange } from '../utils/dataset_selection'; import { DatasetSelectionChange } from '../utils/dataset_selection';
export const useLogExplorerProfile = ( export const useDatasetSelection = (
logExplorerProfileStateService: LogExplorerProfileStateService logExplorerProfileStateService: LogExplorerProfileStateService
) => { ) => {
const datasetSelection = useSelector( const datasetSelection = useSelector(logExplorerProfileStateService, (state) => {
logExplorerProfileStateService, return state.context.datasetSelection;
(state) => state.context.datasetSelection });
);
const handleDatasetSelectionChange: DatasetSelectionChange = useCallback( const handleDatasetSelectionChange: DatasetSelectionChange = useCallback(
(data) => { (data) => {

View file

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

View file

@ -6,8 +6,30 @@
*/ */
import { AllDatasetSelection } from '../../../utils/dataset_selection'; import { AllDatasetSelection } from '../../../utils/dataset_selection';
import { DefaultLogExplorerProfileState } from './types'; import { ControlPanels, DefaultLogExplorerProfileState } from './types';
export const DEFAULT_CONTEXT: DefaultLogExplorerProfileState = { export const DEFAULT_CONTEXT: DefaultLogExplorerProfileState = {
datasetSelection: AllDatasetSelection.create(), 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 { IToasts } from '@kbn/core/public';
import { DiscoverStateContainer } from '@kbn/discover-plugin/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 { isDatasetSelection } from '../../../utils/dataset_selection';
import { createAndSetDataView } from './data_view_service'; import { createAndSetDataView } from './data_view_service';
import { DEFAULT_CONTEXT } from './defaults'; import { DEFAULT_CONTEXT } from './defaults';
@ -15,18 +15,25 @@ import {
createCreateDataViewFailedNotifier, createCreateDataViewFailedNotifier,
createDatasetSelectionRestoreFailedNotifier, createDatasetSelectionRestoreFailedNotifier,
} from './notifications'; } from './notifications';
import type { import {
ControlPanelRT,
LogExplorerProfileContext, LogExplorerProfileContext,
LogExplorerProfileEvent, LogExplorerProfileEvent,
LogExplorerProfileTypestate, LogExplorerProfileTypeState,
} from './types'; } from './types';
import { initializeFromUrl, listenUrlChange } from './url_state_storage_service'; import {
initializeControlPanels,
initializeFromUrl,
listenUrlChange,
subscribeControlGroup,
updateControlPanels,
} from './url_state_storage_service';
export const createPureLogExplorerProfileStateMachine = ( export const createPureLogExplorerProfileStateMachine = (
initialContext: LogExplorerProfileContext 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 */ /** @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>( createMachine<LogExplorerProfileContext, LogExplorerProfileEvent, LogExplorerProfileTypeState>(
{ {
context: initialContext, context: initialContext,
predictableActionArguments: true, predictableActionArguments: true,
@ -53,7 +60,7 @@ export const createPureLogExplorerProfileStateMachine = (
invoke: { invoke: {
src: 'createDataView', src: 'createDataView',
onDone: { onDone: {
target: 'initialized', target: 'initializingControlPanels',
}, },
onError: { onError: {
target: 'initialized', target: 'initialized',
@ -61,33 +68,90 @@ export const createPureLogExplorerProfileStateMachine = (
}, },
}, },
}, },
initializingControlPanels: {
invoke: {
src: 'initializeControlPanels',
onDone: {
target: 'initialized',
actions: ['storeControlPanels'],
},
onError: {
target: 'initialized',
},
},
},
initialized: { initialized: {
initial: 'idle', type: 'parallel',
states: { states: {
idle: { datasetSelection: {
invoke: { initial: 'idle',
src: 'listenUrlChange', states: {
}, idle: {
on: { invoke: {
UPDATE_DATASET_SELECTION: { src: 'listenUrlChange',
target: 'updatingDataView', },
actions: ['storeDatasetSelection'], on: {
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
}, },
DATASET_SELECTION_RESTORE_FAILURE: { updatingDataView: {
target: 'updatingDataView', invoke: {
actions: ['notifyDatasetSelectionRestoreFailed'], src: 'createDataView',
onDone: {
target: 'idle',
actions: ['notifyDataViewUpdate'],
},
onError: {
target: 'idle',
actions: ['notifyCreateDataViewFailed'],
},
},
}, },
}, },
}, },
updatingDataView: { controlGroups: {
invoke: { initial: 'uninitialized',
src: 'createDataView', states: {
onDone: { uninitialized: {
target: 'idle', on: {
INITIALIZE_CONTROL_GROUP_API: {
target: 'idle',
cond: 'controlGroupAPIExists',
actions: ['storeControlGroupAPI'],
},
},
}, },
onError: { idle: {
target: 'idle', invoke: {
actions: ['notifyCreateDataViewFailed'], 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: { services: {
createDataView: createAndSetDataView({ stateContainer }), createDataView: createAndSetDataView({ stateContainer }),
initializeFromUrl: initializeFromUrl({ stateContainer }), initializeFromUrl: initializeFromUrl({ stateContainer }),
initializeControlPanels: initializeControlPanels({ stateContainer }),
listenUrlChange: listenUrlChange({ stateContainer }), listenUrlChange: listenUrlChange({ stateContainer }),
subscribeControlGroup: subscribeControlGroup({ stateContainer }),
updateControlPanels: updateControlPanels({ stateContainer }),
}, },
}); });

View file

@ -5,6 +5,8 @@
* 2.0. * 2.0.
*/ */
import * as rt from 'io-ts';
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { DoneInvokeEvent } from 'xstate'; import { DoneInvokeEvent } from 'xstate';
import type { DatasetEncodingError, DatasetSelection } from '../../../utils/dataset_selection'; import type { DatasetEncodingError, DatasetSelection } from '../../../utils/dataset_selection';
@ -12,9 +14,17 @@ export interface WithDatasetSelection {
datasetSelection: DatasetSelection; datasetSelection: DatasetSelection;
} }
export interface WithControlPanelGroupAPI {
controlGroupAPI: ControlGroupAPI;
}
export interface WithControlPanels {
controlPanels: ControlPanels;
}
export type DefaultLogExplorerProfileState = WithDatasetSelection; export type DefaultLogExplorerProfileState = WithDatasetSelection;
export type LogExplorerProfileTypestate = export type LogExplorerProfileTypeState =
| { | {
value: 'uninitialized'; value: 'uninitialized';
context: WithDatasetSelection; context: WithDatasetSelection;
@ -28,21 +38,37 @@ export type LogExplorerProfileTypestate =
context: WithDatasetSelection; context: WithDatasetSelection;
} }
| { | {
value: 'initialized'; value: 'initializingControlPanels';
context: WithDatasetSelection; context: WithDatasetSelection;
} }
| {
value: 'initialized';
context: WithDatasetSelection & WithControlPanels;
}
| { | {
value: 'initialized.idle'; value: 'initialized.idle';
context: WithDatasetSelection; context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
} }
| { | {
value: 'initialized.updatingDataView'; value: 'initialized.updatingDataView';
context: WithDatasetSelection; 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 = export type LogExplorerProfileEvent =
| { | {
@ -52,5 +78,36 @@ export type LogExplorerProfileEvent =
| { | {
type: 'DATASET_SELECTION_RESTORE_FAILURE'; 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<DatasetEncodingError>
| DoneInvokeEvent<Error>; | 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. * 2.0.
*/ */
import { InvokeCreator } from 'xstate'; import { InvokeCreator } from 'xstate';
import { pick, mapValues } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public'; import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { import {
AllDatasetSelection, AllDatasetSelection,
decodeDatasetSelectionId, decodeDatasetSelectionId,
hydrateDatasetSelection, hydrateDatasetSelection,
isDatasetSelection, isDatasetSelection,
} from '../../../utils/dataset_selection'; } 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 { interface LogExplorerProfileUrlStateDependencies {
stateContainer: DiscoverStateContainer; stateContainer: DiscoverStateContainer;
@ -61,6 +74,20 @@ export const initializeFromUrl =
return extractDatasetSelectionFromIndex({ index, context }); 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 = ({ const extractDatasetSelectionFromIndex = ({
index, index,
context, context,
@ -78,3 +105,134 @@ const extractDatasetSelectionFromIndex = ({
return datasetSelection; 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; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DiscoverStart } from '@kbn/discover-plugin/public'; import { DiscoverStart } from '@kbn/discover-plugin/public';
export type DiscoverLogExplorerPluginSetup = void; export type DiscoverLogExplorerPluginSetup = void;
export type DiscoverLogExplorerPluginStart = void; export type DiscoverLogExplorerPluginStart = void;
export interface DiscoverLogExplorerStartDeps { export interface DiscoverLogExplorerStartDeps {
data: DataPublicPluginStart;
discover: DiscoverStart; discover: DiscoverStart;
} }

View file

@ -13,6 +13,12 @@
"@kbn/io-ts-utils", "@kbn/io-ts-utils",
"@kbn/data-views-plugin", "@kbn/data-views-plugin",
"@kbn/rison", "@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", "@kbn/config-schema",
], ],
"exclude": ["target/**/*"] "exclude": ["target/**/*"]

View file

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

View file

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