[Dashboard][Controls] Options List API & Validation System (#123889)

Created API for Options List with validation and total count. Implemented into Options List Embeddable.

Co-authored-by: andreadelrio <andrea.delrio@elastic.co>
This commit is contained in:
Devon Thomson 2022-02-28 11:41:34 -05:00 committed by GitHub
parent fcfac8c21b
commit 51f2e4d010
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1268 additions and 360 deletions

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { BoolQuery } from '@kbn/es-query';
import { FieldSpec } from '../../../../data_views/common';
import { ControlInput } from '../../types';
export const OPTIONS_LIST_CONTROL = 'optionsListControl';
@ -18,3 +20,17 @@ export interface OptionsListEmbeddableInput extends ControlInput {
singleSelect?: boolean;
loading?: boolean;
}
export interface OptionsListResponse {
suggestions: string[];
totalCardinality: number;
invalidSelections?: string[];
}
export interface OptionsListRequestBody {
filters?: Array<{ bool: BoolQuery }>;
selectedOptions?: string[];
searchString?: string;
fieldSpec?: FieldSpec;
fieldName: string;
}

View file

@ -17,6 +17,7 @@ export interface ParentIgnoreSettings {
ignoreFilters?: boolean;
ignoreQuery?: boolean;
ignoreTimerange?: boolean;
ignoreValidations?: boolean;
}
export type ControlInput = EmbeddableInput & {

View file

@ -13,6 +13,7 @@ import uuid from 'uuid';
import {
getFlightOptionsAsync,
getFlightSearchOptions,
storybookFlightsDataView,
} from '../../../presentation_util/public/mocks';
import {
@ -31,6 +32,9 @@ import { pluginServices, registry } from '../services/storybook';
import { replaceValueSuggestionMethod } from '../services/storybook/data';
import { injectStorybookDataView } from '../services/storybook/data_views';
import { populateStorybookControlFactories } from './storybook_control_factories';
import { OptionsListRequest } from '../services/options_list';
import { OptionsListResponse } from '../control_types/options_list/types';
import { replaceOptionsListMethod } from '../services/storybook/options_list';
export default {
title: 'Controls',
@ -41,6 +45,22 @@ export default {
injectStorybookDataView(storybookFlightsDataView);
replaceValueSuggestionMethod(getFlightOptionsAsync);
const storybookStubOptionsListRequest = async (
request: OptionsListRequest,
abortSignal: AbortSignal
) =>
new Promise<OptionsListResponse>((r) =>
setTimeout(
() =>
r({
suggestions: getFlightSearchOptions(request.field.name, request.searchString),
totalCardinality: 100,
}),
120
)
);
replaceOptionsListMethod(storybookStubOptionsListRequest);
const ControlGroupStoryComponent: FC<{
panels?: ControlsPanels;
edit?: boolean;

View file

@ -11,15 +11,8 @@ import { uniqBy } from 'lodash';
import ReactDOM from 'react-dom';
import deepEqual from 'fast-deep-equal';
import { Filter, uniqFilters } from '@kbn/es-query';
import { EMPTY, merge, pipe, Subscription, concat } from 'rxjs';
import {
distinctUntilChanged,
debounceTime,
catchError,
switchMap,
map,
take,
} from 'rxjs/operators';
import { EMPTY, merge, pipe, Subscription } from 'rxjs';
import { distinctUntilChanged, debounceTime, catchError, switchMap, map } from 'rxjs/operators';
import {
ControlGroupInput,
@ -77,29 +70,34 @@ export class ControlGroupContainer extends Container<
pluginServices.getServices().controls.getControlFactory,
parent
);
const anyChildChangePipe = pipe(
map(() => this.getChildIds()),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) =>
merge(
...newChildIds.map((childId) =>
this.getChild(childId)
.getOutput$()
// Embeddables often throw errors into their output streams.
.pipe(catchError(() => EMPTY))
// when all children are ready start recalculating filters when any child's output changes
this.untilReady().then(() => {
this.recalculateOutput();
const anyChildChangePipe = pipe(
map(() => this.getChildIds()),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) =>
merge(
...newChildIds.map((childId) =>
this.getChild(childId)
.getOutput$()
// Embeddables often throw errors into their output streams.
.pipe(catchError(() => EMPTY))
)
)
)
)
);
);
this.subscriptions.add(
concat(
merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)).pipe(take(1)), // the first time filters are built, don't debounce so that initial filters are built immediately
merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)).pipe(debounceTime(10))
).subscribe(this.recalculateOutput)
);
this.subscriptions.add(
merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe))
.pipe(debounceTime(10))
.subscribe(this.recalculateOutput)
);
});
}
private recalculateOutput = () => {

View file

@ -22,6 +22,32 @@
border-color: darken($euiColorLightestShade, 2%);
}
.optionsList__popoverTitle {
display: flex;
align-items: center;
justify-content: space-between;
}
.optionsList__filterInvalid {
color: $euiTextSubduedColor;
text-decoration: line-through;
margin-left: $euiSizeS;
font-weight: 300;
}
.optionsList__ignoredBadge {
margin-left: $euiSizeS;
}
.optionsList-control-ignored-selection-title {
padding-left: $euiSizeS;
}
.optionsList__selectionInvalid {
text-decoration: line-through;
color: $euiTextSubduedColor;
}
.optionsList--filterBtnWrapper {
height: 100%;
}

View file

@ -10,7 +10,7 @@ import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import classNames from 'classnames';
import { debounce } from 'lodash';
import { debounce, isEmpty } from 'lodash';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from './options_list_reducers';
@ -20,11 +20,16 @@ import { useReduxEmbeddableContext } from '../../../../presentation_util/public'
import './options_list.scss';
import { useStateObservable } from '../../hooks/use_state_observable';
import { OptionsListEmbeddableInput } from './types';
import { DataViewField } from '../../../../data_views/public';
// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input.
// OptionsListComponentState is controlled by the embeddable, but is not considered embeddable input.
export interface OptionsListComponentState {
availableOptions?: string[];
loading: boolean;
field?: DataViewField;
totalCardinality?: number;
availableOptions?: string[];
invalidSelections?: string[];
validSelections?: string[];
}
export const OptionsListComponent = ({
@ -52,10 +57,11 @@ export const OptionsListComponent = ({
);
// useStateObservable to get component state from Embeddable
const { availableOptions, loading } = useStateObservable<OptionsListComponentState>(
componentStateSubject,
componentStateSubject.getValue()
);
const { availableOptions, loading, invalidSelections, validSelections, totalCardinality, field } =
useStateObservable<OptionsListComponentState>(
componentStateSubject,
componentStateSubject.getValue()
);
// debounce loading state so loading doesn't flash when user types
const [buttonLoading, setButtonLoading] = useState(true);
@ -80,12 +86,24 @@ export const OptionsListComponent = ({
[typeaheadSubject]
);
const { selectedOptionsCount, selectedOptionsString } = useMemo(() => {
const { hasSelections, selectionDisplayNode, validSelectionsCount } = useMemo(() => {
return {
selectedOptionsCount: selectedOptions?.length,
selectedOptionsString: selectedOptions?.join(OptionsListStrings.summary.getSeparator()),
hasSelections: !isEmpty(validSelections) || !isEmpty(invalidSelections),
validSelectionsCount: validSelections?.length,
selectionDisplayNode: (
<>
{validSelections && (
<span>{validSelections?.join(OptionsListStrings.summary.getSeparator())}</span>
)}
{invalidSelections && (
<span className="optionsList__filterInvalid">
{invalidSelections.join(OptionsListStrings.summary.getSeparator())}
</span>
)}
</>
),
};
}, [selectedOptions]);
}, [validSelections, invalidSelections]);
const button = (
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>
@ -94,17 +112,15 @@ export const OptionsListComponent = ({
isLoading={buttonLoading}
className={classNames('optionsList--filterBtn', {
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
'optionsList--filterBtnPlaceholder': !selectedOptionsCount,
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
onClick={() => setIsPopoverOpen((openState) => !openState)}
isSelected={isPopoverOpen}
numActiveFilters={selectedOptionsCount}
hasActiveFilters={(selectedOptionsCount ?? 0) > 0}
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
>
{!selectedOptionsCount
? OptionsListStrings.summary.getPlaceholder()
: selectedOptionsString}
{hasSelections ? selectionDisplayNode : OptionsListStrings.summary.getPlaceholder()}
</EuiFilterButton>
</div>
);
@ -127,11 +143,14 @@ export const OptionsListComponent = ({
repositionOnScroll
>
<OptionsListPopover
field={field}
width={dimensions.width}
loading={loading}
searchString={searchString}
updateSearchString={updateSearchString}
totalCardinality={totalCardinality}
availableOptions={availableOptions}
invalidSelections={invalidSelections}
updateSearchString={updateSearchString}
/>
</EuiPopover>
</EuiFilterGroup>

View file

@ -8,47 +8,47 @@
import {
Filter,
buildEsQuery,
compareFilters,
buildPhraseFilter,
buildPhrasesFilter,
COMPARE_ALL_OPTIONS,
} from '@kbn/es-query';
import React from 'react';
import ReactDOM from 'react-dom';
import { isEqual } from 'lodash';
import { isEmpty, isEqual } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs';
import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators';
import { OptionsListComponent, OptionsListComponentState } from './options_list_component';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types';
import { DataView, DataViewField } from '../../../../data_views/public';
import { Embeddable, IContainer } from '../../../../embeddable/public';
import { ControlsDataViewsService } from '../../services/data_views';
import { optionsListReducers } from './options_list_reducers';
import { OptionsListStrings } from './options_list_strings';
import { ControlInput, ControlOutput } from '../..';
import { pluginServices } from '../../services';
import {
withSuspense,
LazyReduxEmbeddableWrapper,
ReduxEmbeddableWrapperPropsWithChildren,
} from '../../../../presentation_util/public';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types';
import { ControlsDataViewsService } from '../../services/data_views';
import { Embeddable, IContainer } from '../../../../embeddable/public';
import { ControlsDataService } from '../../services/data';
import { optionsListReducers } from './options_list_reducers';
import { OptionsListStrings } from './options_list_strings';
import { DataView } from '../../../../data_views/public';
import { ControlInput, ControlOutput } from '../..';
import { pluginServices } from '../../services';
import { ControlsOptionsListService } from '../../services/options_list';
const OptionsListReduxWrapper = withSuspense<
ReduxEmbeddableWrapperPropsWithChildren<OptionsListEmbeddableInput>
>(LazyReduxEmbeddableWrapper);
const diffDataFetchProps = (
current?: OptionsListDataFetchProps,
last?: OptionsListDataFetchProps
last?: OptionsListDataFetchProps,
current?: OptionsListDataFetchProps
) => {
if (!current || !last) return false;
const { filters: currentFilters, ...currentWithoutFilters } = current;
const { filters: lastFilters, ...lastWithoutFilters } = last;
if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false;
if (!compareFilters(lastFilters ?? [], currentFilters ?? [])) return false;
if (!compareFilters(lastFilters ?? [], currentFilters ?? [], COMPARE_ALL_OPTIONS)) return false;
return true;
};
@ -60,9 +60,6 @@ interface OptionsListDataFetchProps {
filters?: ControlInput['filters'];
}
const fieldMissingError = (fieldName: string) =>
new Error(`field ${fieldName} not found in index pattern`);
export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput, ControlOutput> {
public readonly type = OPTIONS_LIST_CONTROL;
public deferEmbeddableLoad = true;
@ -71,17 +68,21 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
private node?: HTMLElement;
// Controls services
private dataService: ControlsDataService;
private dataViewsService: ControlsDataViewsService;
private optionsListService: ControlsOptionsListService;
// Internal data fetching state for this input control.
private typeaheadSubject: Subject<string> = new Subject<string>();
private abortController?: AbortController;
private dataView?: DataView;
private field?: DataViewField;
private searchString = '';
// State to be passed down to component
private componentState: OptionsListComponentState;
private componentStateSubject$ = new BehaviorSubject<OptionsListComponentState>({
invalidSelections: [],
validSelections: [],
loading: true,
});
@ -89,14 +90,28 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
super(input, output, parent); // get filters for initial output...
// Destructure controls services
({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices());
({ dataViews: this.dataViewsService, optionsList: this.optionsListService } =
pluginServices.getServices());
this.componentState = { loading: true };
this.updateComponentState(this.componentState);
this.typeaheadSubject = new Subject<string>();
this.initialize();
}
private initialize = async () => {
const { selectedOptions: initialSelectedOptions } = this.getInput();
if (!initialSelectedOptions) this.setInitializationFinished();
this.runOptionsListQuery().then(async () => {
if (initialSelectedOptions) {
await this.buildFilter();
this.setInitializationFinished();
}
this.setupSubscriptions();
});
};
private setupSubscriptions = () => {
const dataFetchPipe = this.getInput$().pipe(
map((newInput) => ({
@ -111,7 +126,6 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
);
// push searchString changes into a debounced typeahead subject
this.typeaheadSubject = new Subject<string>();
const typeaheadPipe = this.typeaheadSubject.pipe(
tap((newSearchString) => (this.searchString = newSearchString)),
debounceTime(100)
@ -119,30 +133,78 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
// fetch available options when input changes or when search string has changed
this.subscriptions.add(
merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions)
merge(dataFetchPipe, typeaheadPipe)
.pipe(skip(1)) // Skip the first input update because options list query will be run by initialize.
.subscribe(this.runOptionsListQuery)
);
// build filters when selectedOptions change
// build filters when selectedOptions or invalidSelections change
this.subscriptions.add(
this.getInput$()
this.componentStateSubject$
.pipe(
debounceTime(400),
distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)),
debounceTime(100),
distinctUntilChanged((a, b) => isEqual(a.validSelections, b.validSelections)),
skip(1) // skip the first input update because initial filters will be built by initialize.
)
.subscribe(() => this.buildFilter())
);
/**
* when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections.
**/
this.subscriptions.add(
this.getInput$()
.pipe(distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)))
.subscribe(({ selectedOptions: newSelectedOptions }) => {
if (!newSelectedOptions || isEmpty(newSelectedOptions)) {
this.updateComponentState({
validSelections: [],
invalidSelections: [],
});
return;
}
const { invalidSelections } = this.componentStateSubject$.getValue();
const newValidSelections: string[] = [];
const newInvalidSelections: string[] = [];
for (const selectedOption of newSelectedOptions) {
if (invalidSelections?.includes(selectedOption)) {
newInvalidSelections.push(selectedOption);
continue;
}
newValidSelections.push(selectedOption);
}
this.updateComponentState({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
})
);
};
private getCurrentDataView = async (): Promise<DataView> => {
const { dataViewId } = this.getInput();
if (this.dataView && this.dataView.id === dataViewId) return this.dataView;
this.dataView = await this.dataViewsService.get(dataViewId);
if (this.dataView === undefined) {
this.onFatalError(new Error(OptionsListStrings.errors.getDataViewNotFoundError(dataViewId)));
private getCurrentDataViewAndField = async (): Promise<{
dataView: DataView;
field: DataViewField;
}> => {
const { dataViewId, fieldName } = this.getInput();
if (!this.dataView || this.dataView.id !== dataViewId) {
this.dataView = await this.dataViewsService.get(dataViewId);
if (this.dataView === undefined) {
this.onFatalError(
new Error(OptionsListStrings.errors.getDataViewNotFoundError(dataViewId))
);
}
this.updateOutput({ dataViews: [this.dataView] });
}
this.updateOutput({ dataViews: [this.dataView] });
return this.dataView;
if (!this.field || this.field.name !== fieldName) {
this.field = this.dataView.getFieldByName(fieldName);
if (this.field === undefined) {
this.onFatalError(new Error(OptionsListStrings.errors.getDataViewNotFoundError(fieldName)));
}
this.updateComponentState({ field: this.field });
}
return { dataView: this.dataView, field: this.field! };
};
private updateComponentState(changes: Partial<OptionsListComponentState>) {
@ -153,62 +215,67 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.componentStateSubject$.next(this.componentState);
}
private fetchAvailableOptions = async () => {
private runOptionsListQuery = async () => {
this.updateComponentState({ loading: true });
const { ignoreParentSettings, filters, fieldName, query } = this.getInput();
const dataView = await this.getCurrentDataView();
const field = dataView.getFieldByName(fieldName);
const { dataView, field } = await this.getCurrentDataViewAndField();
const { ignoreParentSettings, filters, query, selectedOptions, timeRange } = this.getInput();
if (!field) throw fieldMissingError(fieldName);
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
const { suggestions, invalidSelections, totalCardinality } =
await this.optionsListService.runOptionsListRequest(
{
field,
dataView,
selectedOptions,
searchString: this.searchString,
...(ignoreParentSettings?.ignoreQuery ? {} : { query }),
...(ignoreParentSettings?.ignoreFilters ? {} : { filters }),
...(ignoreParentSettings?.ignoreTimerange ? {} : { timeRange }),
},
this.abortController.signal
);
const boolFilter = [
buildEsQuery(
dataView,
ignoreParentSettings?.ignoreQuery ? [] : query ?? [],
ignoreParentSettings?.ignoreFilters ? [] : filters ?? []
),
];
// TODO Switch between `terms_agg` and `terms_enum` method depending on the value of ignoreParentSettings
// const method = Object.values(ignoreParentSettings || {}).includes(false) ?
const newOptions = await this.dataService.autocomplete.getValueSuggestions({
query: this.searchString,
indexPattern: dataView,
useTimeRange: !ignoreParentSettings?.ignoreTimerange,
method: 'terms_agg', // terms_agg method is required to use timeRange
boolFilter,
field,
});
this.updateComponentState({ availableOptions: newOptions, loading: false });
};
private initialize = async () => {
const initialSelectedOptions = this.getInput().selectedOptions;
if (initialSelectedOptions) {
await this.getCurrentDataView();
await this.buildFilter();
if (!selectedOptions || isEmpty(invalidSelections) || ignoreParentSettings?.ignoreValidations) {
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
loading: false,
});
return;
}
this.setInitializationFinished();
this.setupSubscriptions();
const valid: string[] = [];
const invalid: string[] = [];
for (const selectedOption of selectedOptions) {
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
loading: false,
});
};
private buildFilter = async () => {
const { fieldName, selectedOptions } = this.getInput();
if (!selectedOptions || selectedOptions.length === 0) {
const { validSelections } = this.componentState;
if (!validSelections || isEmpty(validSelections)) {
this.updateOutput({ filters: [] });
return;
}
const dataView = await this.getCurrentDataView();
const field = dataView.getFieldByName(this.getInput().fieldName);
if (!field) throw fieldMissingError(fieldName);
const { dataView, field } = await this.getCurrentDataViewAndField();
let newFilter: Filter;
if (selectedOptions.length === 1) {
newFilter = buildPhraseFilter(field, selectedOptions[0], dataView);
if (validSelections.length === 1) {
newFilter = buildPhraseFilter(field, validSelections[0], dataView);
} else {
newFilter = buildPhrasesFilter(field, selectedOptions, dataView);
newFilter = buildPhrasesFilter(field, validSelections, dataView);
}
newFilter.meta.key = field?.name;
@ -216,11 +283,12 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
};
reload = () => {
this.fetchAvailableOptions();
this.runOptionsListQuery();
};
public destroy = () => {
super.destroy();
this.abortController?.abort();
this.subscriptions.unsubscribe();
};

View file

@ -14,28 +14,38 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
EuiFormRow,
EuiToolTip,
EuiSpacer,
EuiBadge,
EuiIcon,
EuiTitle,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { OptionsListEmbeddableInput } from './types';
import { OptionsListStrings } from './options_list_strings';
import { DataViewField } from '../../../../data_views/public';
import { optionsListReducers } from './options_list_reducers';
import { OptionsListComponentState } from './options_list_component';
import { useReduxEmbeddableContext } from '../../../../presentation_util/public';
export const OptionsListPopover = ({
field,
loading,
searchString,
availableOptions,
totalCardinality,
invalidSelections,
updateSearchString,
width,
}: {
field?: DataViewField;
searchString: string;
totalCardinality?: number;
width: number;
loading: OptionsListComponentState['loading'];
invalidSelections?: string[];
updateSearchString: (newSearchString: string) => void;
availableOptions: OptionsListComponentState['availableOptions'];
}) => {
@ -49,64 +59,94 @@ export const OptionsListPopover = ({
const dispatch = useEmbeddableDispatch();
const { selectedOptions, singleSelect, title } = useEmbeddableSelector((state) => state);
// track selectedOptions in a set for more efficient lookup
// track selectedOptions and invalidSelections in sets for more efficient lookup
const selectedOptionsSet = useMemo(() => new Set<string>(selectedOptions), [selectedOptions]);
const invalidSelectionsSet = useMemo(
() => new Set<string>(invalidSelections),
[invalidSelections]
);
const [showOnlySelected, setShowOnlySelected] = useState(false);
return (
<>
<EuiPopoverTitle paddingSize="s">{title}</EuiPopoverTitle>
<div className="optionsList__actions">
<EuiFormRow fullWidth>
<EuiFlexGroup gutterSize="xs" direction="row" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFieldSearch
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => updateSearchString(event.target.value)}
value={searchString}
data-test-subj="optionsList-control-search-input"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
>
<EuiButtonIcon
size="s"
color="danger"
iconType="eraser"
data-test-subj="optionsList-control-clear-all-selections"
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => dispatch(clearSelections({}))}
{field?.type !== 'boolean' && (
<div className="optionsList__actions">
<EuiFormRow fullWidth>
<EuiFlexGroup
gutterSize="xs"
direction="row"
justifyContent="spaceBetween"
alignItems="center"
>
<EuiFlexItem>
<EuiFieldSearch
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => updateSearchString(event.target.value)}
value={searchString}
data-test-subj="optionsList-control-search-input"
placeholder={
totalCardinality
? OptionsListStrings.popover.getTotalCardinalityPlaceholder(totalCardinality)
: undefined
}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="s"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => setShowOnlySelected(!showOnlySelected)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{invalidSelections && invalidSelections.length > 0 && (
<EuiToolTip
content={OptionsListStrings.popover.getInvalidSelectionsTooltip(
invalidSelections.length
)}
>
<EuiBadge className="optionsList__ignoredBadge" color="warning">
{invalidSelections.length}
</EuiBadge>
</EuiToolTip>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
>
<EuiButtonIcon
size="s"
color="danger"
iconType="eraser"
data-test-subj="optionsList-control-clear-all-selections"
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => dispatch(clearSelections({}))}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="s"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
onClick={() => setShowOnlySelected(!showOnlySelected)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
)}
<div
style={{ width: width > 300 ? width : undefined }}
className="optionsList__items"
@ -145,6 +185,32 @@ export const OptionsListPopover = ({
</div>
</div>
)}
{!isEmpty(invalidSelections) && (
<>
<EuiSpacer size="s" />
<EuiTitle size="xxs" className="optionsList-control-ignored-selection-title">
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(
invalidSelections?.length ?? 0
)}
</label>
</EuiTitle>
<>
{invalidSelections?.map((ignoredSelection, index) => (
<EuiFilterSelectItem
data-test-subj={`optionsList-control-ignored-selection-${ignoredSelection}`}
checked={'on'}
className="optionsList__selectionInvalid"
key={index}
onClick={() => dispatch(deselectOption(ignoredSelection))}
>
{`${ignoredSelection}`}
</EuiFilterSelectItem>
))}
</>
</>
)}
</>
)}
{showOnlySelected && (
@ -152,9 +218,14 @@ export const OptionsListPopover = ({
{selectedOptions &&
selectedOptions.map((availableOption, index) => (
<EuiFilterSelectItem
checked="on"
checked={'on'}
key={index}
onClick={() => dispatch(deselectOption(availableOption))}
className={
invalidSelectionsSet.has(availableOption)
? 'optionsList__selectionInvalid'
: undefined
}
>
{`${availableOption}`}
</EuiFilterSelectItem>

View file

@ -24,6 +24,20 @@ export const optionsListReducers = {
state.selectedOptions = newSelections;
}
},
deselectOptions: (
state: WritableDraft<OptionsListEmbeddableInput>,
action: PayloadAction<string[]>
) => {
for (const optionToDeselect of action.payload) {
if (!state.selectedOptions) return;
const itemIndex = state.selectedOptions.indexOf(optionToDeselect);
if (itemIndex !== -1) {
const newSelections = [...state.selectedOptions];
newSelections.splice(itemIndex, 1);
state.selectedOptions = newSelections;
}
}
},
selectOption: (
state: WritableDraft<OptionsListEmbeddableInput>,
action: PayloadAction<string>
@ -38,6 +52,6 @@ export const optionsListReducers = {
state.selectedOptions = [action.payload];
},
clearSelections: (state: WritableDraft<OptionsListEmbeddableInput>) => {
state.selectedOptions = [];
if (state.selectedOptions) state.selectedOptions = [];
},
};

View file

@ -44,11 +44,11 @@ export const OptionsListStrings = {
popover: {
getLoadingMessage: () =>
i18n.translate('controls.optionsList.popover.loading', {
defaultMessage: 'Loading filters',
defaultMessage: 'Loading options',
}),
getEmptyMessage: () =>
i18n.translate('controls.optionsList.popover.empty', {
defaultMessage: 'No filters found',
defaultMessage: 'No options found',
}),
getSelectionsEmptyMessage: () =>
i18n.translate('controls.optionsList.popover.selectionsEmpty', {
@ -66,6 +66,38 @@ export const OptionsListStrings = {
i18n.translate('controls.optionsList.popover.clearAllSelectionsTitle', {
defaultMessage: 'Clear selections',
}),
getTotalCardinalityTooltip: (totalOptions: number) =>
i18n.translate('controls.optionsList.popover.cardinalityTooltip', {
defaultMessage: '{totalOptions} available options.',
values: { totalOptions },
}),
getTotalCardinalityPlaceholder: (totalOptions: number) =>
i18n.translate('controls.optionsList.popover.cardinalityPlaceholder', {
defaultMessage:
'Search {totalOptions} available {totalOptions, plural, one {option} other {options}}',
values: { totalOptions },
}),
getInvalidSelectionsTitle: (invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsTitle', {
defaultMessage: '{invalidSelectionCount} selected options ignored',
values: { invalidSelectionCount },
}),
getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', {
defaultMessage:
'Ignored {invalidSelectionCount, plural, one {selection} other {selections}}',
values: { invalidSelectionCount },
}),
getInvalidSelectionsAriaLabel: () =>
i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', {
defaultMessage: 'Deselect all ignored selections',
}),
getInvalidSelectionsTooltip: (selectedOptions: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsTooltip', {
defaultMessage:
'{selectedOptions} selected {selectedOptions, plural, one {option} other {options}} {selectedOptions, plural, one {is} other {are}} ignored because {selectedOptions, plural, one {it is} other {they are}} no longer in the data.',
values: { selectedOptions },
}),
},
errors: {
getDataViewNotFoundError: (dataViewId: string) =>
@ -73,5 +105,10 @@ export const OptionsListStrings = {
defaultMessage: 'Could not locate data view: {dataViewId}',
values: { dataViewId },
}),
getfieldNotFoundError: (fieldId: string) =>
i18n.translate('controls.optionsList.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldId}',
values: { fieldId },
}),
},
};

View file

@ -8,19 +8,21 @@
import { CoreSetup, CoreStart, Plugin } from '../../../core/public';
import { pluginServices } from './services';
import { registry } from './services/kibana';
import {
ControlsPluginSetup,
ControlsPluginStart,
ControlsPluginSetupDeps,
ControlsPluginStartDeps,
IEditableControlFactory,
ControlEditorProps,
ControlEmbeddable,
ControlInput,
} from './types';
import { OptionsListEmbeddableFactory } from './control_types/options_list';
import {
OptionsListEmbeddableFactory,
OptionsListEmbeddableInput,
} from './control_types/options_list';
import { ControlGroupContainerFactory, CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL } from '.';
import { controlsService } from './services/kibana/controls';
import { EmbeddableFactory } from '../../embeddable/public';
export class ControlsPlugin
implements
@ -31,63 +33,63 @@ export class ControlsPlugin
ControlsPluginStartDeps
>
{
private inlineEditors: {
[key: string]: {
controlEditorComponent?: (props: ControlEditorProps) => JSX.Element;
presaveTransformFunction?: (
newInput: Partial<ControlInput>,
embeddable?: ControlEmbeddable
) => Partial<ControlInput>;
};
} = {};
private async startControlsKibanaServices(
coreStart: CoreStart,
startPlugins: ControlsPluginStartDeps
) {
const { registry } = await import('./services/kibana');
pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
}
private transferEditorFunctions<I extends ControlInput = ControlInput>(
factoryDef: IEditableControlFactory<I>,
factory: EmbeddableFactory
) {
(factory as IEditableControlFactory<I>).controlEditorComponent =
factoryDef.controlEditorComponent;
(factory as IEditableControlFactory<I>).presaveTransformFunction =
factoryDef.presaveTransformFunction;
}
public setup(
_coreSetup: CoreSetup<ControlsPluginStartDeps, ControlsPluginStart>,
_setupPlugins: ControlsPluginSetupDeps
): ControlsPluginSetup {
_coreSetup.getStartServices().then(([coreStart, deps]) => {
// register control group embeddable factory
const { registerControlType } = controlsService;
// register control group embeddable factory
_coreSetup.getStartServices().then(([, deps]) => {
embeddable.registerEmbeddableFactory(
CONTROL_GROUP_TYPE,
new ControlGroupContainerFactory(deps.embeddable)
);
});
// Options List control factory setup
const optionsListFactoryDef = new OptionsListEmbeddableFactory();
const optionsListFactory = embeddable.registerEmbeddableFactory(
OPTIONS_LIST_CONTROL,
optionsListFactoryDef
)();
this.transferEditorFunctions<OptionsListEmbeddableInput>(
optionsListFactoryDef,
optionsListFactory
);
registerControlType(optionsListFactory);
});
const { embeddable } = _setupPlugins;
// create control type embeddable factories.
const optionsListFactory = new OptionsListEmbeddableFactory();
const editableOptionsListFactory = optionsListFactory as IEditableControlFactory;
this.inlineEditors[OPTIONS_LIST_CONTROL] = {
controlEditorComponent: editableOptionsListFactory.controlEditorComponent,
presaveTransformFunction: editableOptionsListFactory.presaveTransformFunction,
return {
registerControlType,
};
embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory);
return {};
}
public start(coreStart: CoreStart, startPlugins: ControlsPluginStartDeps): ControlsPluginStart {
pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
const { controls: controlsService } = pluginServices.getServices();
const { embeddable } = startPlugins;
// register control types with controls service.
const optionsListFactory = embeddable.getEmbeddableFactory(OPTIONS_LIST_CONTROL);
// Temporarily pass along inline editors - inline editing should be made a first-class feature of embeddables
const editableOptionsListFactory = optionsListFactory as IEditableControlFactory;
const {
controlEditorComponent: optionsListControlEditor,
presaveTransformFunction: optionsListPresaveTransform,
} = this.inlineEditors[OPTIONS_LIST_CONTROL];
editableOptionsListFactory.controlEditorComponent = optionsListControlEditor;
editableOptionsListFactory.presaveTransformFunction = optionsListPresaveTransform;
if (optionsListFactory) controlsService.registerControlType(optionsListFactory);
this.startControlsKibanaServices(coreStart, startPlugins);
const { getControlFactory, getControlTypes } = controlsService;
return {
ContextProvider: pluginServices.getContextProvider(),
controlsService,
getControlFactory,
getControlTypes,
};
}

View file

@ -26,29 +26,3 @@ export interface ControlsService {
getControlTypes: () => string[];
}
export const getCommonControlsService = () => {
const controlsFactoriesMap: ControlTypeRegistry = {};
const registerControlType = (factory: ControlFactory) => {
controlsFactoriesMap[factory.type] = factory;
};
const getControlFactory = <
I extends ControlInput = ControlInput,
O extends ControlOutput = ControlOutput,
E extends ControlEmbeddable<I, O> = ControlEmbeddable<I, O>
>(
type: string
) => {
return controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>;
};
const getControlTypes = () => Object.keys(controlsFactoriesMap);
return {
registerControlType,
getControlFactory,
getControlTypes,
};
};

View file

@ -10,4 +10,5 @@ import { DataPublicPluginStart } from '../../../data/public';
export interface ControlsDataService {
autocomplete: DataPublicPluginStart['autocomplete'];
query: DataPublicPluginStart['query'];
}

View file

@ -0,0 +1,13 @@
/*
* 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 { CoreSetup } from '../../../../core/public';
export interface ControlsHTTPService {
fetch: CoreSetup['http']['fetch'];
}

View file

@ -13,12 +13,19 @@ import { registry as stubRegistry } from './stub';
import { ControlsPluginStart } from '../types';
import { ControlsDataService } from './data';
import { ControlsService } from './controls';
import { ControlsHTTPService } from './http';
import { ControlsOptionsListService } from './options_list';
export interface ControlsServices {
// dependency services
dataViews: ControlsDataViewsService;
overlays: ControlsOverlaysService;
data: ControlsDataService;
http: ControlsHTTPService;
// controls plugin's own services
controls: ControlsService;
optionsList: ControlsOptionsListService;
}
export const pluginServices = new PluginServices<ControlsServices>();
@ -26,7 +33,7 @@ export const pluginServices = new PluginServices<ControlsServices>();
export const getStubPluginServices = (): ControlsPluginStart => {
pluginServices.setRegistry(stubRegistry.start({}));
return {
ContextProvider: pluginServices.getContextProvider(),
controlsService: pluginServices.getServices().controls,
getControlFactory: pluginServices.getServices().controls.getControlFactory,
getControlTypes: pluginServices.getServices().controls.getControlTypes,
};
};

View file

@ -6,8 +6,29 @@
* Side Public License, v 1.
*/
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../..';
import { EmbeddableFactory } from '../../../../embeddable/public';
import { PluginServiceFactory } from '../../../../presentation_util/public';
import { getCommonControlsService, ControlsService } from '../controls';
import { ControlsService, ControlTypeRegistry } from '../controls';
export type ControlsServiceFactory = PluginServiceFactory<ControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();
export const controlsServiceFactory = () => controlsService;
const controlsFactoriesMap: ControlTypeRegistry = {};
// export controls service directly for use in plugin setup lifecycle
export const controlsService: ControlsService = {
registerControlType: (factory: ControlFactory) => {
controlsFactoriesMap[factory.type] = factory;
},
getControlFactory: <
I extends ControlInput = ControlInput,
O extends ControlOutput = ControlOutput,
E extends ControlEmbeddable<I, O> = ControlEmbeddable<I, O>
>(
type: string
) => {
return controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>;
},
getControlTypes: () => Object.keys(controlsFactoriesMap),
};

View file

@ -17,9 +17,10 @@ export type DataServiceFactory = KibanaPluginServiceFactory<
export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => {
const {
data: { autocomplete },
data: { query, autocomplete },
} = startPlugins;
return {
autocomplete,
query,
};
};

View file

@ -0,0 +1,25 @@
/*
* 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 { ControlsHTTPService } from '../http';
import { ControlsPluginStartDeps } from '../../types';
import { KibanaPluginServiceFactory } from '../../../../presentation_util/public';
export type HttpServiceFactory = KibanaPluginServiceFactory<
ControlsHTTPService,
ControlsPluginStartDeps
>;
export const httpServiceFactory: HttpServiceFactory = ({ coreStart }) => {
const {
http: { fetch },
} = coreStart;
return {
fetch,
};
};

View file

@ -19,15 +19,20 @@ import { dataViewsServiceFactory } from './data_views';
import { controlsServiceFactory } from './controls';
import { overlaysServiceFactory } from './overlays';
import { dataServiceFactory } from './data';
import { httpServiceFactory } from './http';
import { optionsListServiceFactory } from './options_list';
export const providers: PluginServiceProviders<
ControlsServices,
KibanaPluginServiceParams<ControlsPluginStartDeps>
> = {
http: new PluginServiceProvider(httpServiceFactory),
data: new PluginServiceProvider(dataServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
dataViews: new PluginServiceProvider(dataViewsServiceFactory),
optionsList: new PluginServiceProvider(optionsListServiceFactory, ['data', 'http']),
controls: new PluginServiceProvider(controlsServiceFactory),
};
export const registry = new PluginServiceRegistry<

View file

@ -0,0 +1,113 @@
/*
* 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 { memoize } from 'lodash';
import dateMath from '@elastic/datemath';
import { buildEsQuery } from '@kbn/es-query';
import { TimeRange } from '../../../../data/public';
import { ControlsOptionsListService, OptionsListRequest } from '../options_list';
import {
OptionsListRequestBody,
OptionsListResponse,
} from '../../control_types/options_list/types';
import { KibanaPluginServiceFactory } from '../../../../presentation_util/public';
import { ControlsPluginStartDeps } from '../../types';
import { ControlsDataService } from '../data';
import { ControlsHTTPService } from '../http';
class OptionsListService implements ControlsOptionsListService {
private data: ControlsDataService;
private http: ControlsHTTPService;
constructor(requiredServices: OptionsListServiceRequiredServices) {
({ data: this.data, http: this.http } = requiredServices);
}
private getRoundedTimeRange = (timeRange: TimeRange) => ({
from: dateMath.parse(timeRange.from)!.startOf('minute').toISOString(),
to: dateMath.parse(timeRange.to)!.endOf('minute').toISOString(),
});
private optionsListCacheResolver = (request: OptionsListRequest) => {
const {
query,
filters,
timeRange,
searchString,
selectedOptions,
field: { name: fieldName },
dataView: { title: dataViewTitle },
} = request;
return [
...(timeRange ? JSON.stringify(this.getRoundedTimeRange(timeRange)) : []), // round timeRange to the minute to avoid cache misses
Math.floor(Date.now() / 1000 / 60), // Only cache results for a minute in case data changes in ES index
selectedOptions?.join(','),
JSON.stringify(filters),
JSON.stringify(query),
dataViewTitle,
searchString,
fieldName,
].join('|');
};
private cachedOptionsListRequest = memoize(
async (request: OptionsListRequest, abortSignal: AbortSignal) => {
const index = request.dataView.title;
const requestBody = this.getRequestBody(request);
return await this.http.fetch<OptionsListResponse>(
`/api/kibana/controls/optionsList/${index}`,
{
body: JSON.stringify(requestBody),
signal: abortSignal,
method: 'POST',
}
);
},
this.optionsListCacheResolver
);
private getRequestBody = (request: OptionsListRequest): OptionsListRequestBody => {
const timeService = this.data.query.timefilter.timefilter;
const { query, filters, dataView, timeRange, field, ...passThroughProps } = request;
const timeFilter = timeRange ? timeService.createFilter(dataView, timeRange) : undefined;
const filtersToUse = [...(filters ?? []), ...(timeFilter ? [timeFilter] : [])];
const esFilters = [buildEsQuery(dataView, query ?? [], filtersToUse ?? [])];
return {
...passThroughProps,
filters: esFilters,
fieldName: field.name,
fieldSpec: field.toSpec?.(),
};
};
public runOptionsListRequest = async (request: OptionsListRequest, abortSignal: AbortSignal) => {
try {
return await this.cachedOptionsListRequest(request, abortSignal);
} catch (error) {
// Remove rejected results from memoize cache
this.cachedOptionsListRequest.cache.delete(this.optionsListCacheResolver(request));
return {} as OptionsListResponse;
}
};
}
export interface OptionsListServiceRequiredServices {
data: ControlsDataService;
http: ControlsHTTPService;
}
export type OptionsListServiceFactory = KibanaPluginServiceFactory<
ControlsOptionsListService,
ControlsPluginStartDeps,
OptionsListServiceRequiredServices
>;
export const optionsListServiceFactory: OptionsListServiceFactory = (core, requiredServices) => {
return new OptionsListService(requiredServices);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { Filter, Query } from '@kbn/es-query';
import { TimeRange } from '../../../data/public';
import { DataView, DataViewField } from '../../../data_views/public';
import { OptionsListRequestBody, OptionsListResponse } from '../control_types/options_list/types';
export type OptionsListRequest = Omit<
OptionsListRequestBody,
'filters' | 'fieldName' | 'fieldSpec'
> & {
timeRange?: TimeRange;
field: DataViewField;
dataView: DataView;
filters?: Filter[];
query?: Query;
};
export interface ControlsOptionsListService {
runOptionsListRequest: (
request: OptionsListRequest,
abortSignal: AbortSignal
) => Promise<OptionsListResponse>;
}

View file

@ -22,4 +22,5 @@ export const dataServiceFactory: DataServiceFactory = () => ({
autocomplete: {
getValueSuggestions: valueSuggestionMethod,
} as unknown as DataPublicPluginStart['autocomplete'],
query: {} as unknown as DataPublicPluginStart['query'],
});

View file

@ -15,16 +15,22 @@ import {
import { ControlsServices } from '..';
import { dataServiceFactory } from './data';
import { overlaysServiceFactory } from './overlays';
import { controlsServiceFactory } from './controls';
import { dataViewsServiceFactory } from './data_views';
import { httpServiceFactory } from '../stub/http';
import { optionsListServiceFactory } from './options_list';
import { controlsServiceFactory } from '../stub/controls';
export type { ControlsServices } from '..';
export const providers: PluginServiceProviders<ControlsServices> = {
dataViews: new PluginServiceProvider(dataViewsServiceFactory),
http: new PluginServiceProvider(httpServiceFactory),
data: new PluginServiceProvider(dataServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
optionsList: new PluginServiceProvider(optionsListServiceFactory),
};
export const pluginServices = new PluginServices<ControlsServices>();

View file

@ -0,0 +1,35 @@
/*
* 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 { PluginServiceFactory } from '../../../../presentation_util/public';
import { OptionsListResponse } from '../../control_types/options_list/types';
import { ControlsOptionsListService, OptionsListRequest } from '../options_list';
export type OptionsListServiceFactory = PluginServiceFactory<ControlsOptionsListService>;
let optionsListRequestMethod = async (request: OptionsListRequest, abortSignal: AbortSignal) =>
new Promise<OptionsListResponse>((r) =>
setTimeout(
() =>
r({
suggestions: [],
totalCardinality: 100,
}),
120
)
);
export const replaceOptionsListMethod = (
newMethod: (request: OptionsListRequest, abortSignal: AbortSignal) => Promise<OptionsListResponse>
) => (optionsListRequestMethod = newMethod);
export const optionsListServiceFactory: OptionsListServiceFactory = () => {
return {
runOptionsListRequest: optionsListRequestMethod,
};
};

View file

@ -6,8 +6,36 @@
* Side Public License, v 1.
*/
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../..';
import { EmbeddableFactory } from '../../../../embeddable/public';
import { PluginServiceFactory } from '../../../../presentation_util/public';
import { getCommonControlsService, ControlsService } from '../controls';
import { ControlsService, ControlTypeRegistry } from '../controls';
export type ControlsServiceFactory = PluginServiceFactory<ControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();
export const controlsServiceFactory = () => getStubControlsService();
export const getStubControlsService = () => {
const controlsFactoriesMap: ControlTypeRegistry = {};
const registerControlType = (factory: ControlFactory) => {
controlsFactoriesMap[factory.type] = factory;
};
const getControlFactory = <
I extends ControlInput = ControlInput,
O extends ControlOutput = ControlOutput,
E extends ControlEmbeddable<I, O> = ControlEmbeddable<I, O>
>(
type: string
) => {
return controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>;
};
const getControlTypes = () => Object.keys(controlsFactoriesMap);
return {
registerControlType,
getControlFactory,
getControlTypes,
};
};

View file

@ -6,8 +6,12 @@
* Side Public License, v 1.
*/
import { HttpResponse } from '../../../../../core/public';
import { PluginServiceFactory } from '../../../../presentation_util/public';
import { getCommonControlsService, ControlsService } from '../controls';
import { ControlsHTTPService } from '../http';
export type ControlsServiceFactory = PluginServiceFactory<ControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();
type HttpServiceFactory = PluginServiceFactory<ControlsHTTPService>;
export const httpServiceFactory: HttpServiceFactory = () => ({
fetch: async () => ({} as unknown as HttpResponse),
});

View file

@ -12,17 +12,22 @@ import {
PluginServiceRegistry,
} from '../../../../presentation_util/public';
import { ControlsServices } from '..';
import { httpServiceFactory } from './http';
import { overlaysServiceFactory } from './overlays';
import { controlsServiceFactory } from './controls';
import { dataServiceFactory } from '../storybook/data';
import { dataViewsServiceFactory } from '../storybook/data_views';
import { optionsListServiceFactory } from '../storybook/options_list';
export const providers: PluginServiceProviders<ControlsServices> = {
http: new PluginServiceProvider(httpServiceFactory),
data: new PluginServiceProvider(dataServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
dataViews: new PluginServiceProvider(dataViewsServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
optionsList: new PluginServiceProvider(optionsListServiceFactory),
};
export const registry = new PluginServiceRegistry<ControlsServices>(providers);

View file

@ -54,13 +54,13 @@ export interface ControlEditorProps<T extends ControlInput = ControlInput> {
/**
* Plugin types
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ControlsPluginSetup {}
export interface ControlsPluginSetup {
registerControlType: ControlsService['registerControlType'];
}
export interface ControlsPluginStart {
controlsService: ControlsService;
ContextProvider: React.FC;
getControlFactory: ControlsService['getControlFactory'];
getControlTypes: ControlsService['getControlTypes'];
}
export interface ControlsPluginSetupDeps {

View file

@ -0,0 +1,197 @@
/*
* 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 { get, isEmpty } from 'lodash';
import { schema } from '@kbn/config-schema';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Observable } from 'rxjs';
import {
OptionsListRequestBody,
OptionsListResponse,
} from '../../../common/control_types/options_list/types';
import { CoreSetup, ElasticsearchClient } from '../../../../../core/server';
import { getKbnServerError, reportServerError } from '../../../../kibana_utils/server';
import { PluginSetup as DataPluginSetup } from '../../../../data/server';
import { FieldSpec, getFieldSubtypeNested } from '../../../../data_views/common';
export const setupOptionsListSuggestionsRoute = (
{ http }: CoreSetup,
getAutocompleteSettings: DataPluginSetup['autocomplete']['getAutocompleteSettings']
) => {
const router = http.createRouter();
router.post(
{
path: '/api/kibana/controls/optionsList/{index}',
validate: {
params: schema.object(
{
index: schema.string(),
},
{ unknowns: 'allow' }
),
body: schema.object(
{
fieldName: schema.string(),
filters: schema.maybe(schema.any()),
fieldSpec: schema.maybe(schema.any()),
searchString: schema.maybe(schema.string()),
selectedOptions: schema.maybe(schema.arrayOf(schema.string())),
},
{ unknowns: 'allow' }
),
},
},
async (context, request, response) => {
try {
const suggestionRequest: OptionsListRequestBody = request.body;
const { index } = request.params;
const esClient = context.core.elasticsearch.client.asCurrentUser;
const suggestionsResponse = await getOptionsListSuggestions({
abortedEvent$: request.events.aborted$,
request: suggestionRequest,
esClient,
index,
});
return response.ok({ body: suggestionsResponse });
} catch (e) {
const kbnErr = getKbnServerError(e);
return reportServerError(response, kbnErr);
}
}
);
const getOptionsListSuggestions = async ({
abortedEvent$,
esClient,
request,
index,
}: {
request: OptionsListRequestBody;
abortedEvent$: Observable<void>;
esClient: ElasticsearchClient;
index: string;
}): Promise<OptionsListResponse> => {
const abortController = new AbortController();
abortedEvent$.subscribe(() => abortController.abort());
const { fieldName, searchString, selectedOptions, filters, fieldSpec } = request;
const body = getOptionsListBody(fieldName, fieldSpec, searchString, selectedOptions, filters);
const rawEsResult = await esClient.search({ index, body }, { signal: abortController.signal });
// parse raw ES response into OptionsListSuggestionResponse
const totalCardinality = get(rawEsResult, 'aggregations.unique_terms.value');
const suggestions = get(rawEsResult, 'aggregations.suggestions.buckets')?.map(
(suggestion: { key: string; key_as_string: string }) =>
fieldSpec?.type === 'string' ? suggestion.key : suggestion.key_as_string
);
const rawInvalidSuggestions = get(rawEsResult, 'aggregations.validation.buckets') as {
[key: string]: { doc_count: number };
};
const invalidSelections =
rawInvalidSuggestions && !isEmpty(rawInvalidSuggestions)
? Object.entries(rawInvalidSuggestions)
?.filter(([, value]) => value?.doc_count === 0)
?.map(([key]) => key)
: undefined;
return {
suggestions,
totalCardinality,
invalidSelections,
};
};
const getOptionsListBody = (
fieldName: string,
fieldSpec?: FieldSpec,
searchString?: string,
selectedOptions?: string[],
filters: estypes.QueryDslQueryContainer[] = []
) => {
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators
const getEscapedQuery = (q: string = '') =>
q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`);
// Helps ensure that the regex is not evaluated eagerly against the terms dictionary
const executionHint = 'map' as const;
// Suggestions
const shardSize = 10;
const suggestionsAgg = {
terms: {
field: fieldName,
// terms on boolean fields don't support include
...(fieldSpec?.type !== 'boolean' && {
include: `${getEscapedQuery(searchString ?? '')}.*`,
}),
execution_hint: executionHint,
shard_size: shardSize,
},
};
// Validation
const selectedOptionsFilters = selectedOptions?.reduce((acc, currentOption) => {
acc[currentOption] = { match: { [fieldName]: currentOption } };
return acc;
}, {} as { [key: string]: { match: { [key: string]: string } } });
const validationAgg =
selectedOptionsFilters && !isEmpty(selectedOptionsFilters)
? {
filters: {
filters: selectedOptionsFilters,
},
}
: undefined;
const { terminateAfter, timeout } = getAutocompleteSettings();
const body = {
size: 0,
timeout: `${timeout}ms`,
terminate_after: terminateAfter,
query: {
bool: {
filter: filters,
},
},
aggs: {
suggestions: suggestionsAgg,
...(validationAgg ? { validation: validationAgg } : {}),
unique_terms: {
cardinality: {
field: fieldName,
},
},
},
};
const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec);
if (subTypeNested) {
return {
...body,
aggs: {
nestedSuggestions: {
nested: {
path: subTypeNested.nested.path,
},
aggs: body.aggs,
},
},
};
}
return body;
};
};

View file

@ -7,21 +7,27 @@
*/
import { CoreSetup, Plugin } from 'kibana/server';
import { EmbeddableSetup } from '../../embeddable/server';
import { PluginSetup as DataSetup } from '../../data/server';
import { setupOptionsListSuggestionsRoute } from './control_types/options_list/options_list_suggestions_route';
import { controlGroupContainerPersistableStateServiceFactory } from './control_group/control_group_container_factory';
import { optionsListPersistableStateServiceFactory } from './control_types/options_list/options_list_embeddable_factory';
interface SetupDeps {
embeddable: EmbeddableSetup;
data: DataSetup;
}
export class ControlsPlugin implements Plugin<object, object, SetupDeps> {
public setup(core: CoreSetup, plugins: SetupDeps) {
plugins.embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory());
public setup(core: CoreSetup, { embeddable, data }: SetupDeps) {
embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory());
plugins.embeddable.registerEmbeddableFactory(
controlGroupContainerPersistableStateServiceFactory(plugins.embeddable)
embeddable.registerEmbeddableFactory(
controlGroupContainerPersistableStateServiceFactory(embeddable)
);
setupOptionsListSuggestionsRoute(core, data.autocomplete.getAutocompleteSettings);
return {};
}

View file

@ -85,11 +85,15 @@ export class DashboardContainerFactoryDefinition
ControlGroupOutput,
ControlGroupContainer
>(CONTROL_GROUP_TYPE);
const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput;
const controlGroup = await controlsGroupFactory?.create({
id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultDashboardControlGroupInput(),
...(initialInput.controlGroupInput ?? {}),
viewMode: initialInput.viewMode,
id: `control_group_${initialInput.id ?? 'new_dashboard'}`,
...(controlGroupInput ?? {}),
timeRange,
viewMode,
filters,
query,
});
const { DashboardContainer: DashboardContainerEmbeddable } = await import(
'./dashboard_container'

View file

@ -74,10 +74,11 @@ export const syncDashboardControlGroup = async ({
})
);
const compareAllFilters = (a?: Filter[], b?: Filter[]) =>
compareFilters(a ?? [], b ?? [], COMPARE_ALL_OPTIONS);
const dashboardRefetchDiff: DiffChecks = {
filters: (a, b) =>
compareFilters((a as Filter[]) ?? [], (b as Filter[]) ?? [], COMPARE_ALL_OPTIONS),
lastReloadRequestTime: deepEqual,
filters: (a, b) => compareAllFilters(a as Filter[], b as Filter[]),
timeRange: deepEqual,
query: deepEqual,
viewMode: deepEqual,
@ -130,7 +131,14 @@ export const syncDashboardControlGroup = async ({
subscriptions.add(
controlGroup
.getOutput$()
.subscribe(() => dashboardContainer.updateInput({ lastReloadRequestTime: Date.now() }))
.pipe(
distinctUntilChanged(({ filters: filtersA }, { filters: filtersB }) =>
compareAllFilters(filtersA, filtersB)
)
)
.subscribe(() => {
dashboardContainer.updateInput({ lastReloadRequestTime: Date.now() });
})
);
return {

View file

@ -78,6 +78,11 @@ export const syncDashboardContainerInput = (
)
.subscribe(() => {
applyStateChangesToContainer({ ...syncDashboardContainerProps, force: forceRefresh });
// If this dashboard has a control group, reload the control group when the refresh button is manually pressed.
if (forceRefresh && dashboardContainer.controlGroup) {
dashboardContainer.controlGroup.reload();
}
forceRefresh = false;
})
);

View file

@ -6,22 +6,37 @@
* Side Public License, v 1.
*/
import moment from 'moment';
import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
import { registerRoutes } from './routes';
import { ConfigSchema } from '../../config';
export class AutocompleteService implements Plugin<void> {
private valueSuggestionsEnabled: boolean = true;
private autocompleteSettings: ConfigSchema['autocomplete']['valueSuggestions'];
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {
initializerContext.config.create().subscribe((configUpdate) => {
this.valueSuggestionsEnabled = configUpdate.autocomplete.valueSuggestions.enabled;
this.autocompleteSettings = configUpdate.autocomplete.valueSuggestions;
});
this.autocompleteSettings =
this.initializerContext.config.get<ConfigSchema>().autocomplete.valueSuggestions;
}
public setup(core: CoreSetup) {
if (this.valueSuggestionsEnabled) registerRoutes(core, this.initializerContext.config.create());
const { terminateAfter, timeout } = this.autocompleteSettings;
return {
getAutocompleteSettings: () => ({
terminateAfter: moment.duration(terminateAfter).asMilliseconds(),
timeout: moment.duration(timeout).asMilliseconds(),
}),
};
}
public start() {}
}
/** @public **/
export type AutocompleteSetup = ReturnType<AutocompleteService['setup']>;

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { AutocompleteService } from './autocomplete_service';
export { AutocompleteService, type AutocompleteSetup } from './autocomplete_service';

View file

@ -17,10 +17,16 @@ import {
} from '../../field_formats/server/mocks';
import { createIndexPatternsStartMock } from './data_views/mocks';
import { DataRequestHandlerContext } from './search';
import { AutocompleteSetup } from './autocomplete';
const autocompleteSetupMock: jest.Mocked<AutocompleteSetup> = {
getAutocompleteSettings: jest.fn(),
};
function createSetupContract() {
return {
search: createSearchSetupMock(),
autocomplete: autocompleteSetupMock,
/**
* @deprecated - use directly from "fieldFormats" plugin instead
*/

View file

@ -21,12 +21,14 @@ import { AutocompleteService } from './autocomplete';
import { FieldFormatsSetup, FieldFormatsStart } from '../../field_formats/server';
import { getUiSettings } from './ui_settings';
import { QuerySetup } from './query';
import { AutocompleteSetup } from './autocomplete/autocomplete_service';
interface DataEnhancements {
search: SearchEnhancements;
}
export interface DataPluginSetup {
autocomplete: AutocompleteSetup;
search: ISearchSetup;
query: QuerySetup;
/**
@ -91,7 +93,6 @@ export class DataServerPlugin
) {
this.scriptsService.setup(core);
const querySetup = this.queryService.setup(core);
this.autocompleteService.setup(core);
this.kqlTelemetryService.setup(core, { usageCollection });
core.uiSettings.register(getUiSettings(core.docLinks));
@ -103,6 +104,7 @@ export class DataServerPlugin
});
return {
autocomplete: this.autocompleteService.setup(core),
__enhance: (enhancements: DataEnhancements) => {
searchSetup.__enhance(enhancements.search);
},

View file

@ -105,6 +105,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('Interact with options list on dashboard', async () => {
let controlId: string;
before(async () => {
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
@ -113,105 +114,221 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
fieldName: 'sound.keyword',
title: 'Animal Sounds',
});
controlId = (await dashboardControls.getAllControlIds())[0];
});
it('Shows available options in options list', async () => {
const controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
});
describe('Apply dashboard query and filters to controls', async () => {
it('Applies dashboard query to options list control', async () => {
await queryBar.setQuery('isDog : true ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
it('Can search options list for available options', async () => {
const controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSearchForOption('meo');
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(['meow']);
});
await dashboardControls.optionsListPopoverClearSearch();
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
});
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'ruff',
'bark',
'grrr',
'bow ow ow',
'grr',
]);
});
it('Applies dashboard query to options list control', async () => {
await queryBar.setQuery('isDog : true ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
const controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'ruff',
'bark',
'grrr',
'bow ow ow',
'grr',
]);
await queryBar.setQuery('');
await queryBar.submitQuery();
});
await queryBar.setQuery('');
await queryBar.submitQuery();
});
it('Applies dashboard filters to options list control', async () => {
await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']);
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
it('Applies dashboard filters to options list control', async () => {
await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']);
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
const controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(3);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'ruff',
'bark',
'bow ow ow',
]);
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(3);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'ruff',
'bark',
'bow ow ow',
]);
});
});
await filterBar.removeAllFilters();
it('Does not apply disabled dashboard filters to options list control', async () => {
await filterBar.toggleFilterEnabled('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8);
});
await filterBar.toggleFilterEnabled('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
});
it('Negated filters apply to options control', async () => {
await filterBar.toggleFilterNegated('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'hiss',
'grrr',
'meow',
'growl',
'grr',
]);
});
});
after(async () => {
await filterBar.removeAllFilters();
});
});
it('Can select multiple available options', async () => {
const controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('hiss');
await dashboardControls.optionsListPopoverSelectOption('grr');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
describe('Selections made in control apply to dashboard', async () => {
it('Shows available options in options list', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('Can search options list for available options', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSearchForOption('meo');
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'meow',
]);
});
await dashboardControls.optionsListPopoverClearSearch();
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('Can select multiple available options', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('hiss');
await dashboardControls.optionsListPopoverSelectOption('grr');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('Selected options appear in control', async () => {
const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId);
expect(selectionString).to.be('hiss, grr');
});
it('Applies options list control options to dashboard', async () => {
await retry.try(async () => {
expect(await pieChart.getPieSliceCount()).to.be(2);
});
});
it('Applies options list control options to dashboard by default on open', async () => {
await dashboard.gotoDashboardLandingPage();
await header.waitUntilLoadingHasFinished();
await dashboard.clickUnsavedChangesContinueEditing('New Dashboard');
await header.waitUntilLoadingHasFinished();
expect(await pieChart.getPieSliceCount()).to.be(2);
const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId);
expect(selectionString).to.be('hiss, grr');
});
after(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverClearSelections();
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
});
it('Selected options appear in control', async () => {
const controlIds = await dashboardControls.getAllControlIds();
const selectionString = await dashboardControls.optionsListGetSelectionsString(
controlIds[0]
);
expect(selectionString).to.be('hiss, grr');
});
describe('Options List validation', async () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('meow');
await dashboardControls.optionsListPopoverSelectOption('bark');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
it('Applies options list control options to dashboard', async () => {
await retry.try(async () => {
it('Can mark selections invalid with Query', async () => {
await queryBar.setQuery('isDog : false ');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(4);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'hiss',
'meow',
'growl',
'grr',
'Ignored selection',
'bark',
]);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
// only valid selections are applied as filters.
expect(await pieChart.getPieSliceCount()).to.be(1);
});
it('can make invalid selections valid again if the parent filter changes', async () => {
await queryBar.setQuery('');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'hiss',
'ruff',
'bark',
'grrr',
'meow',
'growl',
'grr',
'bow ow ow',
]);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
expect(await pieChart.getPieSliceCount()).to.be(2);
});
});
it('Applies options list control options to dashboard by default on open', async () => {
await dashboard.gotoDashboardLandingPage();
await header.waitUntilLoadingHasFinished();
await dashboard.clickUnsavedChangesContinueEditing('New Dashboard');
await header.waitUntilLoadingHasFinished();
expect(await pieChart.getPieSliceCount()).to.be(2);
it('Can mark multiple selections invalid with Filter', async () => {
await filterBar.addFilter('sound.keyword', 'is', ['hiss']);
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
const controlIds = await dashboardControls.getAllControlIds();
const selectionString = await dashboardControls.optionsListGetSelectionsString(
controlIds[0]
);
expect(selectionString).to.be('hiss, grr');
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1);
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([
'hiss',
'Ignored selections',
'meow',
'bark',
]);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
// only valid selections are applied as filters.
expect(await pieChart.getPieSliceCount()).to.be(1);
});
});
});
});

View file

@ -87,6 +87,12 @@ export class FilterBarService extends FtrService {
await this.header.awaitGlobalLoadingIndicatorHidden();
}
public async toggleFilterNegated(key: string): Promise<void> {
await this.testSubjects.click(`~filter & ~filter-key-${key}`);
await this.testSubjects.click(`negateFilter`);
await this.header.awaitGlobalLoadingIndicatorHidden();
}
public async isFilterPinned(key: string): Promise<boolean> {
const filter = await this.testSubjects.find(`~filter & ~filter-key-${key}`);
return (await filter.getAttribute('data-test-subj')).includes('filter-pinned');