mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
fcfac8c21b
commit
51f2e4d010
39 changed files with 1268 additions and 360 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface ParentIgnoreSettings {
|
|||
ignoreFilters?: boolean;
|
||||
ignoreQuery?: boolean;
|
||||
ignoreTimerange?: boolean;
|
||||
ignoreValidations?: boolean;
|
||||
}
|
||||
|
||||
export type ControlInput = EmbeddableInput & {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = [];
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -10,4 +10,5 @@ import { DataPublicPluginStart } from '../../../data/public';
|
|||
|
||||
export interface ControlsDataService {
|
||||
autocomplete: DataPublicPluginStart['autocomplete'];
|
||||
query: DataPublicPluginStart['query'];
|
||||
}
|
||||
|
|
13
src/plugins/controls/public/services/http.ts
Normal file
13
src/plugins/controls/public/services/http.ts
Normal 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'];
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -17,9 +17,10 @@ export type DataServiceFactory = KibanaPluginServiceFactory<
|
|||
|
||||
export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => {
|
||||
const {
|
||||
data: { autocomplete },
|
||||
data: { query, autocomplete },
|
||||
} = startPlugins;
|
||||
return {
|
||||
autocomplete,
|
||||
query,
|
||||
};
|
||||
};
|
||||
|
|
25
src/plugins/controls/public/services/kibana/http.ts
Normal file
25
src/plugins/controls/public/services/kibana/http.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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<
|
||||
|
|
113
src/plugins/controls/public/services/kibana/options_list.ts
Normal file
113
src/plugins/controls/public/services/kibana/options_list.ts
Normal 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);
|
||||
};
|
31
src/plugins/controls/public/services/options_list.ts
Normal file
31
src/plugins/controls/public/services/options_list.ts
Normal 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>;
|
||||
}
|
|
@ -22,4 +22,5 @@ export const dataServiceFactory: DataServiceFactory = () => ({
|
|||
autocomplete: {
|
||||
getValueSuggestions: valueSuggestionMethod,
|
||||
} as unknown as DataPublicPluginStart['autocomplete'],
|
||||
query: {} as unknown as DataPublicPluginStart['query'],
|
||||
});
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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']>;
|
||||
|
|
|
@ -6,4 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { AutocompleteService } from './autocomplete_service';
|
||||
export { AutocompleteService, type AutocompleteSetup } from './autocomplete_service';
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue