[controls] add filters, query, and timeRange props to ControlGroupRenderer and create search example (#147581)

Part of https://github.com/elastic/kibana/issues/145428

PR makes the following changes:
1) updates ControlGroupRenderer component with declarative properties
for filters, query, and timeRange.
2) creates a search example showing how to use controls to narrow
results
3) Updates redux example to use web logs sample data set
4) Updates existing uses of ControlGroupRenderer to use new props.

<img width="600" alt="Screen Shot 2022-12-14 at 4 29 58 PM"
src="https://user-images.githubusercontent.com/373691/207719012-28771203-27c3-45c0-a8ac-2bf96c10f641.png">

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-12-19 13:44:39 -05:00 committed by GitHub
parent 486b2e0068
commit b5d3a63516
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 114 deletions

View file

@ -7,5 +7,12 @@
"version": "1.0.0",
"kibanaVersion": "kibana",
"ui": true,
"requiredPlugins": ["data", "developerExamples", "presentationUtil", "controls"]
"requiredPlugins": [
"controls",
"data",
"developerExamples",
"embeddable",
"navigation",
"presentationUtil"
]
}

View file

@ -8,34 +8,36 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { EuiSpacer } from '@elastic/eui';
import { AppMountParameters } from '@kbn/core/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { ControlsExampleStartDeps } from './plugin';
import { BasicReduxExample } from './basic_redux_example';
import { SearchExample } from './search_example';
const ControlsExamples = ({ dataViewId }: { dataViewId?: string }) => {
const examples = dataViewId ? (
<>
<BasicReduxExample dataViewId={dataViewId} />
</>
) : (
<div>{'Please install e-commerce sample data to run controls examples.'}</div>
);
return (
export const renderApp = async (
{ data, navigation }: ControlsExampleStartDeps,
{ element }: AppMountParameters
) => {
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
const examples =
dataViews.length > 0 ? (
<>
<SearchExample dataView={dataViews[0]} navigation={navigation} data={data} />
<EuiSpacer size="xl" />
<BasicReduxExample dataViewId={dataViews[0].id!} />
</>
) : (
<div>{'Install web logs sample data to run controls examples.'}</div>
);
ReactDOM.render(
<KibanaPageTemplate>
<KibanaPageTemplate.Header pageTitle="Controls as a Building Block" />
<KibanaPageTemplate.Section>{examples}</KibanaPageTemplate.Section>
</KibanaPageTemplate>
</KibanaPageTemplate>,
element
);
};
export const renderApp = async (
{ data }: ControlsExampleStartDeps,
{ element }: AppMountParameters
) => {
const dataViews = await data.dataViews.find('kibana_sample_data_ecommerce');
const dataViewId = dataViews.length > 0 ? dataViews[0].id : undefined;
ReactDOM.render(<ControlsExamples dataViewId={dataViewId} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -15,15 +15,8 @@ import {
ControlStyle,
} from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import {
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { EuiButtonGroup, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
@ -44,51 +37,36 @@ export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
const controlStyle = select((state) => state.explicitInput.controlStyle);
return (
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>
<p>Choose a style for your control group:</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonGroup
legend="Text style"
options={[
{
id: `oneLine`,
label: 'One line',
value: 'oneLine' as ControlStyle,
},
{
id: `twoLine`,
label: 'Two lines',
value: 'twoLine' as ControlStyle,
},
]}
idSelected={controlStyle}
onChange={(id, value) => {
dispatch(setControlStyle(value));
}}
type="single"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
<EuiButtonGroup
legend="Text style"
options={[
{
id: `oneLine`,
label: 'One line',
value: 'oneLine' as ControlStyle,
},
{
id: `twoLine`,
label: 'Two lines',
value: 'twoLine' as ControlStyle,
},
]}
idSelected={controlStyle}
onChange={(id, value) => {
dispatch(setControlStyle(value));
}}
type="single"
/>
);
};
return (
<>
<EuiTitle>
<h2>Basic Redux Example</h2>
<h2>Redux example</h2>
</EuiTitle>
<EuiText>
<p>
This example uses the redux context from the control group container in order to
dynamically change the style of the control group.
</p>
<p>Use the redux context from the control group to set layout style.</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
@ -105,17 +83,22 @@ export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
getInitialInput={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
dataViewId,
fieldName: 'customer_first_name.keyword',
width: 'small',
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await builder.addDataControlFromField(initialInput, {
dataViewId,
fieldName: 'customer_last_name.keyword',
fieldName: 'bytes',
width: 'medium',
grow: false,
title: 'Last Name',
grow: true,
title: 'Bytes',
});
return initialInput;
return {
...initialInput,
viewMode: ViewMode.VIEW,
};
}}
/>
</EuiPanel>

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const PLUGIN_ID = 'controlsExamples';

View file

@ -13,9 +13,11 @@ import {
CoreStart,
Plugin,
} from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import img from './control_group_image.png';
import { PLUGIN_ID } from './constants';
interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
@ -23,6 +25,7 @@ interface SetupDeps {
export interface ControlsExampleStartDeps {
data: DataPublicPluginStart;
navigation: NavigationPublicPluginStart;
}
export class ControlsExamplePlugin
@ -30,7 +33,7 @@ export class ControlsExamplePlugin
{
public setup(core: CoreSetup<ControlsExampleStartDeps>, { developerExamples }: SetupDeps) {
core.application.register({
id: 'controlsExamples',
id: PLUGIN_ID,
title: 'Controls examples',
navLinkStatus: AppNavLinkStatus.hidden,
async mount(params: AppMountParameters) {

View file

@ -0,0 +1,168 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import uuid from 'uuid/v4';
import { lastValueFrom } from 'rxjs';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import {
EuiCallOut,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { LazyControlGroupRenderer, ControlGroupContainer } from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { PLUGIN_ID } from './constants';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
interface Props {
data: DataPublicPluginStart;
dataView: DataView;
navigation: NavigationPublicPluginStart;
}
export const SearchExample = ({ data, dataView, navigation }: Props) => {
const [controlFilters, setControlFilters] = useState<Filter[]>([]);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const [hits, setHits] = useState(0);
const [filters, setFilters] = useState<Filter[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [query, setQuery] = useState<Query | undefined>({
language: 'kuery',
query: '',
});
const [timeRange, setTimeRange] = useState<TimeRange>({ from: 'now-7d', to: 'now' });
useEffect(() => {
if (!controlGroup) {
return;
}
const subscription = controlGroup.onFiltersPublished$.subscribe((newFilters) => {
setControlFilters([...newFilters]);
});
return () => {
subscription.unsubscribe();
};
}, [controlGroup]);
useEffect(() => {
const abortController = new AbortController();
const search = async () => {
setIsSearching(true);
const searchSource = await data.search.searchSource.create();
searchSource.setField('index', dataView);
searchSource.setField('size', 0);
searchSource.setField('filter', [
...filters,
...controlFilters,
data.query.timefilter.timefilter.createFilter(dataView, timeRange),
] as Filter[]);
searchSource.setField('query', query);
const { rawResponse: resp } = await lastValueFrom(
searchSource.fetch$({
abortSignal: abortController.signal,
sessionId: uuid(),
legacyHitsTotal: false,
})
);
const total = resp.hits?.total as undefined | { relation: string; value: number };
if (total !== undefined) {
setHits(total.value);
}
setIsSearching(false);
};
search().catch((error) => {
setIsSearching(false);
if (error.name === 'AbortError') {
// ignore abort errors
} else {
// eslint-disable-next-line no-console
console.error(error);
}
});
return () => {
abortController.abort();
};
}, [controlFilters, data, dataView, filters, query, timeRange]);
return (
<>
<EuiTitle>
<h2>Search example</h2>
</EuiTitle>
<EuiText>
<p>
Pass filters, query, and time range to narrow controls. Combine search bar filters with
controls filters to narrow results.
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<navigation.ui.TopNavMenu
appName={PLUGIN_ID}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
filters={filters}
indexPatterns={[dataView]}
onFiltersUpdated={(newFilters) => {
// filterManager.setFilters populates filter.meta so filter pill has pretty title
data.query.filterManager.setFilters(newFilters);
setFilters(newFilters);
}}
onQuerySubmit={({ dateRange, query: newQuery }) => {
setQuery(newQuery);
setTimeRange(dateRange);
}}
query={query}
showSearchBar={true}
/>
<ControlGroupRenderer
filters={filters}
getInitialInput={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
dataViewId: dataView.id!,
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await builder.addDataControlFromField(initialInput, {
dataViewId: dataView.id!,
fieldName: 'bytes',
width: 'medium',
grow: true,
title: 'Bytes',
});
return {
...initialInput,
viewMode: ViewMode.VIEW,
};
}}
onLoadComplete={async (newControlGroup) => {
setControlGroup(newControlGroup);
}}
query={query}
timeRange={timeRange}
/>
<EuiCallOut title="Search results">
{isSearching ? <EuiLoadingSpinner size="l" /> : <p>Hits: {hits}</p>}
</EuiCallOut>
</EuiPanel>
</>
);
};

View file

@ -17,6 +17,7 @@
{ "path": "../developer_examples/tsconfig.json" },
{ "path": "../../src/plugins/data/tsconfig.json" },
{ "path": "../../src/plugins/controls/tsconfig.json" },
{ "path": "../../src/plugins/navigation/tsconfig.json" },
{ "path": "../../src/plugins/presentation_util/tsconfig.json" }
]
}

View file

@ -7,11 +7,14 @@
*/
import uuid from 'uuid';
import { isEqual } from 'lodash';
import useLifecycles from 'react-use/lib/useLifecycles';
import React, { useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import type { Filter, TimeRange, Query } from '@kbn/es-query';
import { compareFilters } from '@kbn/es-query';
import { pluginServices } from '../services';
import { getDefaultControlGroupInput } from '../../common';
@ -26,16 +29,22 @@ import { controlGroupReducers } from './state/control_group_reducers';
import { controlGroupInputBuilder } from './control_group_input_builder';
export interface ControlGroupRendererProps {
onLoadComplete?: (controlGroup: ControlGroupContainer) => void;
filters?: Filter[];
getInitialInput: (
initialInput: Partial<ControlGroupInput>,
builder: typeof controlGroupInputBuilder
) => Promise<Partial<ControlGroupInput>>;
onLoadComplete?: (controlGroup: ControlGroupContainer) => void;
timeRange?: TimeRange;
query?: Query;
}
export const ControlGroupRenderer = ({
onLoadComplete,
getInitialInput,
filters,
timeRange,
query,
}: ControlGroupRendererProps) => {
const controlGroupRef = useRef(null);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
@ -74,6 +83,24 @@ export const ControlGroupRenderer = ({
}
);
useEffect(() => {
if (!controlGroup) {
return;
}
if (
(timeRange && !isEqual(controlGroup.getInput().timeRange, timeRange)) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters ?? []) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
});
}
}, [query, filters, controlGroup, timeRange]);
return <div ref={controlGroupRef} />;
};

View file

@ -8,8 +8,7 @@
import React, { useEffect, useState } from 'react';
import { ControlGroupContainer, CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Filter, TimeRange, compareFilters } from '@kbn/es-query';
import { isEqual } from 'lodash';
import { Filter, TimeRange } from '@kbn/es-query';
import { LazyControlsRenderer } from './lazy_controls_renderer';
import { useControlPanels } from '../hooks/use_control_panels_url_state';
@ -44,21 +43,26 @@ export const ControlsContent: React.FC<Props> = ({
if (!controlGroup) {
return;
}
if (
!isEqual(controlGroup.getInput().timeRange, timeRange) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
const filtersSubscription = controlGroup.onFiltersPublished$.subscribe((newFilters) => {
setPanelFilters([...newFilters]);
});
const inputSubscription = controlGroup
.getInput$()
.subscribe(({ panels, filters: currentFilters }) => {
setControlPanels(panels);
if (currentFilters?.length === 0) {
setPanelFilters([]);
}
});
}
}, [query, filters, controlGroup, timeRange]);
return () => {
filtersSubscription.unsubscribe();
inputSubscription.unsubscribe();
};
}, [controlGroup, setControlPanels, setPanelFilters]);
return (
<LazyControlsRenderer
filters={filters}
getInitialInput={async () => ({
id: dataViewId,
type: CONTROL_GROUP_TYPE,
@ -74,16 +78,9 @@ export const ControlsContent: React.FC<Props> = ({
})}
onLoadComplete={(newControlGroup) => {
setControlGroup(newControlGroup);
newControlGroup.onFiltersPublished$.subscribe((newFilters) => {
setPanelFilters([...newFilters]);
});
newControlGroup.getInput$().subscribe(({ panels, filters: currentFilters }) => {
setControlPanels(panels);
if (currentFilters?.length === 0) {
setPanelFilters([]);
}
});
}}
query={query}
timeRange={timeRange}
/>
);
};

View file

@ -31,7 +31,6 @@ export interface Props {
export class Timeslider extends Component<Props, {}> {
private _isMounted: boolean = false;
private _controlGroup?: ControlGroupContainer | undefined;
private readonly _subscriptions = new Subscription();
componentWillUnmount() {
@ -39,17 +38,6 @@ export class Timeslider extends Component<Props, {}> {
this._subscriptions.unsubscribe();
}
componentDidUpdate() {
if (
this._controlGroup &&
!_.isEqual(this._controlGroup.getInput().timeRange, this.props.timeRange)
) {
this._controlGroup.updateInput({
timeRange: this.props.timeRange,
});
}
}
componentDidMount() {
this._isMounted = true;
}
@ -71,9 +59,8 @@ export class Timeslider extends Component<Props, {}> {
return;
}
this._controlGroup = controlGroup;
this._subscriptions.add(
this._controlGroup
controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) =>
@ -84,7 +71,7 @@ export class Timeslider extends Component<Props, {}> {
// use waitForTimesliceToLoad$ observable to wait until next frame loaded
// .pipe(first()) waits until the first value is emitted from an observable and then automatically unsubscribes
this.props.waitForTimesliceToLoad$.pipe(first()).subscribe(() => {
this._controlGroup!.anyControlOutputConsumerLoading$.next(false);
controlGroup.anyControlOutputConsumerLoading$.next(false);
});
this.props.setTimeslice(
@ -105,6 +92,7 @@ export class Timeslider extends Component<Props, {}> {
<ControlGroupRenderer
onLoadComplete={this._onLoadComplete}
getInitialInput={this._getInitialInput}
timeRange={this.props.timeRange}
/>
</div>
);