From 33d40175b7f6e8a6eb97f3021dde2188d5af9f62 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 14 Oct 2021 19:31:21 -0400 Subject: [PATCH 01/13] Ported options list to use Redux. added selection management and selection clear buttons to options list. Options list clears selections when field or index pattern change --- .../public/lib/containers/container.ts | 1 + .../__stories__/controls_service_stub.ts | 6 +- .../controls/__stories__/flights.ts | 21 +- .../__stories__/input_controls.stories.tsx | 34 +++- .../storybook_control_factories.ts | 12 +- .../component/control_frame_component.tsx | 18 +- .../control_group/editor/edit_control.tsx | 20 +- .../options_list/options_list.scss | 14 ++ .../options_list/options_list_component.tsx | 74 ++++--- .../options_list/options_list_editor.tsx | 75 +++++-- .../options_list/options_list_embeddable.tsx | 172 ++++++---------- .../options_list_popover_component.tsx | 191 +++++++++++++----- .../options_list/options_list_reducers.ts | 37 ++++ .../options_list/options_list_strings.ts | 23 +++ .../generic_embeddable_store.ts | 3 + .../redux_embeddable_wrapper.tsx | 35 ++-- 16 files changed, 482 insertions(+), 254 deletions(-) create mode 100644 src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 06133fb2160c0..928a187f36f00 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -78,6 +78,7 @@ export abstract class Container< if (!this.input.panels[id]) { throw new PanelNotFoundError(); } + const panels = { panels: { ...this.input.panels, diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts index faaa155249949..3f89e2e549d2a 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts @@ -8,7 +8,7 @@ import { ControlsService } from '../controls_service'; import { InputControlFactory } from '../../../services/controls'; -import { flightFields, getEuiSelectableOptions } from './flights'; +import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; export const getControlsServiceStub = () => { @@ -16,8 +16,8 @@ export const getControlsServiceStub = () => { const optionsListFactoryStub = new OptionsListEmbeddableFactory( ({ field, search }) => - new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), + new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), + () => Promise.resolve([{ title: 'demo data flights', fields: [] }]), () => Promise.resolve(flightFields) ); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts index e405b704796ec..941b91c0c92f1 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts @@ -7,22 +7,17 @@ */ import { map, uniq } from 'lodash'; -import { EuiSelectableOption } from '@elastic/eui'; - import { flights } from '../../fixtures/flights'; export type Flight = typeof flights[number]; export type FlightField = keyof Flight; -export const getOptions = (field: string) => uniq(map(flights, field)).sort(); +export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); -export const getEuiSelectableOptions = (field: string, search?: string): EuiSelectableOption[] => { - const options = getOptions(field) - .map((option) => ({ - label: option + '', - searchableLabel: option + '', - })) - .filter((option) => !search || option.label.toLowerCase().includes(search.toLowerCase())); +export const getFlightSearchOptions = (field: string, search?: string): string[] => { + const options = getFlightOptions(field) + .map((option) => option + '') + .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); if (options.length > 10) options.length = 10; return options; }; @@ -57,4 +52,8 @@ export const flightFieldLabels: Record = { timestamp: 'Timestamp', }; -export const flightFields = Object.keys(flightFieldLabels) as FlightField[]; +export const flightFields = Object.keys(flightFieldLabels).map((field) => ({ + name: field, + type: 'string', + aggregatable: true, +})); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx index 66f1d8b36399e..f984b7c996a03 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -64,9 +64,15 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Origin City', id: 'optionsList1', - indexPattern: 'demo data flights', - field: 'OriginCityName', - defaultSelections: ['Toronto'], + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'OriginCityName', + type: 'string', + aggregatable: true, + }, + selectedOptions: ['Toronto'], } as OptionsListEmbeddableInput, }, optionsList2: { @@ -76,9 +82,15 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Destination City', id: 'optionsList2', - indexPattern: 'demo data flights', - field: 'DestCityName', - defaultSelections: ['London'], + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'DestCityName', + type: 'string', + aggregatable: true, + }, + selectedOptions: ['London'], } as OptionsListEmbeddableInput, }, optionsList3: { @@ -88,8 +100,14 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Carrier', id: 'optionsList3', - indexPattern: 'demo data flights', - field: 'Carrier', + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'Carrier', + type: 'string', + aggregatable: true, + }, } as OptionsListEmbeddableInput, }, }} diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts index 3048adc74d8c7..deb5b85336f27 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { flightFields, getEuiSelectableOptions } from './flights'; +import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; import { InputControlFactory, PresentationControlsService } from '../../../services/controls'; @@ -15,8 +15,14 @@ export const populateStorybookControlFactories = ( ) => { const optionsListFactoryStub = new OptionsListEmbeddableFactory( ({ field, search }) => - new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), + new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), + () => + Promise.resolve([ + { + title: 'demo data flights', + fields: [], + }, + ]), () => Promise.resolve(flightFields) ); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 103ce6dd0e27c..3c3876afb7763 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -21,6 +21,7 @@ import { EditControlButton } from '../editor/edit_control'; import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; import { ControlGroupStrings } from '../control_group_strings'; +import { pluginServices } from '../../../../services'; export interface ControlFrameProps { customPrepend?: JSX.Element; @@ -36,6 +37,10 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } = useReduxContainerContext(); const { controlStyle } = useEmbeddableSelector((state) => state); + // Presentation Services Context + const { overlays } = pluginServices.getHooks(); + const { openConfirm } = overlays.useService(); + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); const [title, setTitle] = useState(); @@ -63,7 +68,18 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con removeEmbeddable(embeddableId)} + onClick={() => + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + } + }) + } iconType="cross" color="danger" /> diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx index 58c59c8f84fe0..db15db71fd996 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -20,6 +20,7 @@ import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { InputControlInput } from '../../../../services/controls'; export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Presentation Services Context @@ -54,13 +55,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const factory = getControlFactory(panel.type); const embeddable = await untilEmbeddableLoaded(embeddableId); + let inputToReturn: Partial = {}; + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); let removed = false; const onCancel = (ref: OverlayRef) => { if ( removed || - (isEqual(latestPanelState.current.explicitInput, panel.explicitInput) && + (isEqual(latestPanelState.current.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && isEqual(latestPanelState.current.width, panel.width)) ) { ref.close(); @@ -73,7 +79,6 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => buttonColor: 'danger', }).then((confirmed) => { if (confirmed) { - updateInputForChild(embeddableId, panel.explicitInput); dispatch(setControlWidth({ width: panel.width, embeddableId })); ref.close(); } @@ -99,13 +104,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => } }); }} - updateTitle={(newTitle) => updateInputForChild(embeddableId, { title: newTitle })} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => updateInputForChild(embeddableId, partialInput), + onChange: (partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }, initialInput: embeddable.getInput(), })} onCancel={() => onCancel(flyoutInstance)} - onSave={() => flyoutInstance.close()} + onSave={() => { + updateInputForChild(embeddableId, inputToReturn); + flyoutInstance.close(); + }} updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} />, reduxContainerContext diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss index e9a4ef215733e..b74a08d96c8c3 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss @@ -7,10 +7,24 @@ height: 100%; } +.optionsList--loadingOverlay { + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + background-color: $euiColorEmptyShade; +} + .optionsList--items { @include euiScrollBar; overflow-y: auto; + position: relative; + min-height: $euiSize * 5; max-height: $euiSize * 30; width: $euiSize * 25; max-width: 100%; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index 0d12c69fdab46..ccd2d4841cc03 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -6,48 +6,65 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; - -import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectableOption } from '@elastic/eui'; import { Subject } from 'rxjs'; -import { OptionsListStrings } from './options_list_strings'; + +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { OptionsListEmbeddableInput } from './options_list_embeddable'; import { OptionsListPopover } from './options_list_popover_component'; +import { optionsListReducers } from './options_list_reducers'; +import { OptionsListStrings } from './options_list_strings'; import './options_list.scss'; import { useStateObservable } from '../../hooks/use_state_observable'; +// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface OptionsListComponentState { - availableOptions?: EuiSelectableOption[]; - selectedOptionsString?: string; - selectedOptionsCount?: number; - twoLineLayout?: boolean; - searchString?: string; + availableOptions?: string[]; loading: boolean; } export const OptionsListComponent = ({ - componentStateSubject, typeaheadSubject, - updateOption, + componentStateSubject, }: { - componentStateSubject: Subject; typeaheadSubject: Subject; - updateOption: (index: number) => void; + componentStateSubject: Subject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const optionsListState = useStateObservable(componentStateSubject, { - loading: true, - }); + const [searchString, setSearchString] = useState(''); - const { - selectedOptionsString, - selectedOptionsCount, - availableOptions, - twoLineLayout, - searchString, - loading, - } = optionsListState; + // Redux embeddable Context to get state from Embeddable input + const { useEmbeddableSelector } = useReduxEmbeddableContext< + OptionsListEmbeddableInput, + typeof optionsListReducers + >(); + const { twoLineLayout, selectedOptions } = useEmbeddableSelector((state) => state); + + // useStateObservable to get component state from Embeddable + const { availableOptions, loading } = useStateObservable( + componentStateSubject, + { + loading: true, + } + ); + + const updateSearchString = useCallback( + (newSearchString: string) => { + typeaheadSubject.next(newSearchString); + setSearchString(newSearchString); + }, + [typeaheadSubject] + ); + + const { selectedOptionsCount, selectedOptionsString } = useMemo(() => { + return { + selectedOptionsCount: selectedOptions?.length, + selectedOptionsString: selectedOptions?.join(OptionsListStrings.summary.getSeparator()), + }; + }, [selectedOptions]); const button = ( setIsPopoverOpen((openState) => !openState)} isSelected={isPopoverOpen} - numFilters={availableOptions?.length ?? 0} - hasActiveFilters={(selectedOptionsCount ?? 0) > 0} + numFilters={availableOptions?.length ?? 0} // Remove this once https://github.com/elastic/eui/pull/5268 is in an EUI release numActiveFilters={selectedOptionsCount} + hasActiveFilters={(selectedOptionsCount ?? 0) > 0} > {!selectedOptionsCount ? OptionsListStrings.summary.getPlaceholder() : selectedOptionsString} @@ -79,15 +96,14 @@ export const OptionsListComponent = ({ anchorClassName="optionsList--anchorOverride" closePopover={() => setIsPopoverOpen(false)} panelPaddingSize="none" - anchorPosition="upLeft" + anchorPosition="downCenter" ownFocus repositionOnScroll > diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx index 3e5770da22ce9..b8c945ecb61cc 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx @@ -9,6 +9,7 @@ import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; +import { IFieldType, IIndexPattern } from '../../../../../../data/public'; import { ControlEditorProps, GetControlEditorComponentProps } from '../../types'; import { OptionsListEmbeddableInput, @@ -25,10 +26,13 @@ interface OptionsListEditorProps extends ControlEditorProps { } interface OptionsListEditorState { - availableIndexPatterns: Array>; - indexPattern?: string; - availableFields: Array>; - field?: string; + indexPatternSelectOptions: Array>; + availableIndexPatterns?: { [key: string]: IIndexPattern }; + indexPattern?: IIndexPattern; + + fieldSelectOptions: Array>; + availableFields?: { [key: string]: IFieldType }; + field?: IFieldType; } export const OptionsListEditor = ({ @@ -41,11 +45,17 @@ export const OptionsListEditor = ({ const [state, setState] = useState({ indexPattern: initialInput?.indexPattern, field: initialInput?.field, - availableIndexPatterns: [], - availableFields: [], + indexPatternSelectOptions: [], + fieldSelectOptions: [], }); - const applySelection = ({ field, indexPattern }: { field?: string; indexPattern?: string }) => { + const applySelection = ({ + field, + indexPattern, + }: { + field?: IFieldType; + indexPattern?: IIndexPattern; + }) => { const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) }; /** * apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable @@ -60,24 +70,43 @@ export const OptionsListEditor = ({ useMount(() => { (async () => { - const indexPatterns = (await fetchIndexPatterns()).map((indexPattern) => ({ - value: indexPattern, - inputDisplay: indexPattern, + const newIndexPatterns = await fetchIndexPatterns(); + const newAvailableIndexPatterns = newIndexPatterns.reduce( + (acc: { [key: string]: IIndexPattern }, curr) => ((acc[curr.title] = curr), acc), + {} + ); + const newIndexPatternSelectOptions = newIndexPatterns.map((indexPattern) => ({ + value: indexPattern.title, + inputDisplay: indexPattern.title, + })); + setState((currentState) => ({ + ...currentState, + availableIndexPatterns: newAvailableIndexPatterns, + indexPatternSelectOptions: newIndexPatternSelectOptions, })); - setState((currentState) => ({ ...currentState, availableIndexPatterns: indexPatterns })); })(); }); useEffect(() => { (async () => { - let availableFields: Array> = []; + let newFieldSelectOptions: Array> = []; + let newAvailableFields: { [key: string]: IFieldType } = {}; if (state.indexPattern) { - availableFields = (await fetchFields(state.indexPattern)).map((field) => ({ - value: field, - inputDisplay: field, + const newFields = await fetchFields(state.indexPattern); + newAvailableFields = newFields.reduce( + (acc: { [key: string]: IFieldType }, curr) => ((acc[curr.name] = curr), acc), + {} + ); + newFieldSelectOptions = newFields.map((field) => ({ + value: field.name, + inputDisplay: field.displayName ?? field.name, })); } - setState((currentState) => ({ ...currentState, availableFields })); + setState((currentState) => ({ + ...currentState, + fieldSelectOptions: newFieldSelectOptions, + availableFields: newAvailableFields, + })); })(); }, [state.indexPattern, fetchFields]); @@ -90,17 +119,19 @@ export const OptionsListEditor = ({ <> applySelection({ indexPattern })} - valueOfSelected={state.indexPattern} + options={state.indexPatternSelectOptions} + onChange={(patternTitle) => + applySelection({ indexPattern: state.availableIndexPatterns?.[patternTitle] }) + } + valueOfSelected={state.indexPattern?.title} /> applySelection({ field })} - valueOfSelected={state.field} + options={state.fieldSelectOptions} + onChange={(fieldName) => applySelection({ field: state.availableFields?.[fieldName] })} + valueOfSelected={state.field?.name} /> diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 97a128c3e84eb..93330772d7cad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -8,26 +8,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { merge, Subject } from 'rxjs'; import deepEqual from 'fast-deep-equal'; -import { EuiSelectableOption } from '@elastic/eui'; -import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators'; +import { merge, Subject, Subscription } from 'rxjs'; +import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { esFilters } from '../../../../../../data/public'; -import { OptionsListStrings } from './options_list_strings'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { isEqual } from 'lodash'; +import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; import { InputControlInput, InputControlOutput } from '../../../../services/controls'; +import { esFilters, IIndexPattern, IFieldType } from '../../../../../../data/public'; +import { Embeddable, IContainer } from '../../../../../../embeddable/public'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; - -const toggleAvailableOptions = ( - indices: number[], - availableOptions: EuiSelectableOption[], - enabled?: boolean -) => { - const newAvailableOptions = [...availableOptions]; - indices.forEach((index) => (newAvailableOptions[index].checked = enabled ? 'on' : undefined)); - return newAvailableOptions; -}; +import { optionsListReducers } from './options_list_reducers'; const diffDataFetchProps = ( current?: OptionsListDataFetchProps, @@ -42,28 +33,29 @@ const diffDataFetchProps = ( }; interface OptionsListDataFetchProps { - field: string; search?: string; - indexPattern: string; + field: IFieldType; + indexPattern: IIndexPattern; query?: InputControlInput['query']; filters?: InputControlInput['filters']; timeRange?: InputControlInput['timeRange']; } -export type OptionsListIndexPatternFetcher = () => Promise; // TODO: use the proper types here. -export type OptionsListFieldFetcher = (indexPattern: string) => Promise; // TODO: use the proper types here. +export type OptionsListIndexPatternFetcher = () => Promise; +export type OptionsListFieldFetcher = (indexPattern: IIndexPattern) => Promise; -export type OptionsListDataFetcher = ( - props: OptionsListDataFetchProps -) => Promise; +export type OptionsListDataFetcher = (props: OptionsListDataFetchProps) => Promise; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends InputControlInput { - field: string; - indexPattern: string; + field: IFieldType; + indexPattern: IIndexPattern; + + selectedOptions?: string[]; singleSelect?: boolean; - defaultSelections?: string[]; + loading?: boolean; } + export class OptionsListEmbeddable extends Embeddable< OptionsListEmbeddableInput, InputControlOutput @@ -72,8 +64,8 @@ export class OptionsListEmbeddable extends Embeddable< private node?: HTMLElement; // internal state for this input control. - private selectedOptions: Set; private typeaheadSubject: Subject = new Subject(); + private searchString = ''; private componentState: OptionsListComponentState; private componentStateSubject$ = new Subject(); @@ -85,110 +77,74 @@ export class OptionsListEmbeddable extends Embeddable< this.componentStateSubject$.next(this.componentState); } + private subscriptions: Subscription = new Subscription(); + constructor( input: OptionsListEmbeddableInput, output: InputControlOutput, private fetchData: OptionsListDataFetcher, parent?: IContainer ) { - super(input, output, parent); + super({ ...input, loading: true }, output, parent); this.fetchData = fetchData; - // populate default selections from input - this.selectedOptions = new Set(input.defaultSelections ?? []); - const { selectedOptionsCount, selectedOptionsString } = this.buildSelectedOptionsString(); + const dataFetchPipe = this.getInput$().pipe( + map((newInput) => ({ + field: newInput.field, + indexPattern: newInput.indexPattern, + query: newInput.query, + filters: newInput.filters, + timeRange: newInput.timeRange, + })), + distinctUntilChanged(diffDataFetchProps) + ); - // fetch available options when input changes or when search string has changed + // push searchString changes into a debounced typeahead subject + this.typeaheadSubject = new Subject(); const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => this.updateComponentState({ searchString: newSearchString })), - debounceTime(100) + tap((newSearchString) => (this.searchString = newSearchString), debounceTime(100)) ); - const inputPipe = this.getInput$().pipe( - map( - (newInput) => ({ - field: newInput.field, - indexPattern: newInput.indexPattern, - query: newInput.query, - filters: newInput.filters, - timeRange: newInput.timeRange, - }), - distinctUntilChanged(diffDataFetchProps) - ) + + // fetch available options when input changes or when search string has changed + this.subscriptions.add( + merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions) ); - merge(typeaheadPipe, inputPipe).subscribe(this.fetchAvailableOptions); - // push changes from input into component state - this.getInput$().subscribe((newInput) => { - if (newInput.twoLineLayout !== this.componentState.twoLineLayout) - this.updateComponentState({ twoLineLayout: newInput.twoLineLayout }); - }); + // clear all selections when field or index pattern change + this.subscriptions.add( + this.getInput$() + .pipe( + distinctUntilChanged( + (a, b) => isEqual(a.field, b.field) && isEqual(a.indexPattern, b.indexPattern) + ), + skip(1) // skip the first change to preserve default selections after init + ) + .subscribe(() => this.updateInput({ selectedOptions: [] })) + ); - this.componentState = { - loading: true, - selectedOptionsCount, - selectedOptionsString, - twoLineLayout: input.twoLineLayout, - }; + this.componentState = { loading: true }; this.updateComponentState(this.componentState); } private fetchAvailableOptions = async () => { this.updateComponentState({ loading: true }); - const { indexPattern, timeRange, filters, field, query } = this.getInput(); - let newOptions = await this.fetchData({ - search: this.componentState.searchString, + const newOptions = await this.fetchData({ + search: this.searchString, indexPattern, timeRange, filters, field, query, }); - - // We now have new 'availableOptions', we need to ensure the selected options are still selected in the new list. - const enabledIndices: number[] = []; - this.selectedOptions?.forEach((selectedOption) => { - const optionIndex = newOptions.findIndex( - (availableOption) => availableOption.label === selectedOption - ); - if (optionIndex >= 0) enabledIndices.push(optionIndex); - }); - newOptions = toggleAvailableOptions(enabledIndices, newOptions, true); - this.updateComponentState({ loading: false, availableOptions: newOptions }); + this.updateComponentState({ availableOptions: newOptions, loading: false }); }; - private updateOption = (index: number) => { - const item = this.componentState.availableOptions?.[index]; - if (!item) return; - const toggleOff = item.checked === 'on'; - - // update availableOptions to show selection check marks - const newAvailableOptions = toggleAvailableOptions( - [index], - this.componentState.availableOptions ?? [], - !toggleOff - ); - this.componentState.availableOptions = newAvailableOptions; - - // update selectedOptions string - if (toggleOff) this.selectedOptions.delete(item.label); - else this.selectedOptions.add(item.label); - const { selectedOptionsString, selectedOptionsCount } = this.buildSelectedOptionsString(); - this.updateComponentState({ selectedOptionsString, selectedOptionsCount }); + public destroy = () => { + super.destroy(); + this.subscriptions.unsubscribe(); }; - private buildSelectedOptionsString(): { - selectedOptionsString: string; - selectedOptionsCount: number; - } { - const selectedOptionsArray = Array.from(this.selectedOptions ?? []); - const selectedOptionsString = selectedOptionsArray.join( - OptionsListStrings.summary.getSeparator() - ); - const selectedOptionsCount = selectedOptionsArray.length; - return { selectedOptionsString, selectedOptionsCount }; - } - reload = () => { this.fetchAvailableOptions(); }; @@ -199,11 +155,15 @@ export class OptionsListEmbeddable extends Embeddable< } this.node = node; ReactDOM.render( - , + + embeddable={this} + reducers={optionsListReducers} + > + + , node ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 4bfce9eb377e9..8e406f3e0249f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -6,76 +6,165 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { - EuiFieldSearch, EuiFilterSelectItem, - EuiIcon, EuiLoadingChart, EuiPopoverTitle, - EuiSelectableOption, + EuiFieldSearch, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiFormRow, EuiSpacer, + EuiIcon, } from '@elastic/eui'; -import { Subject } from 'rxjs'; import { OptionsListStrings } from './options_list_strings'; - -interface OptionsListPopoverProps { - loading: boolean; - typeaheadSubject: Subject; - searchString?: string; - updateOption: (index: number) => void; - availableOptions?: EuiSelectableOption[]; -} +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { OptionsListEmbeddableInput } from './options_list_embeddable'; +import { optionsListReducers } from './options_list_reducers'; +import { OptionsListComponentState } from './options_list_component'; export const OptionsListPopover = ({ loading, - updateOption, searchString, - typeaheadSubject, availableOptions, -}: OptionsListPopoverProps) => { + updateSearchString, +}: { + searchString: string; + loading: OptionsListComponentState['loading']; + updateSearchString: (newSearchString: string) => void; + availableOptions: OptionsListComponentState['availableOptions']; +}) => { + // Redux embeddable container Context + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { selectOption, deselectOption, clearSelections }, + } = useReduxEmbeddableContext(); + + const dispatch = useEmbeddableDispatch(); + const { selectedOptions } = useEmbeddableSelector((state) => state); + + // track selectedOptions in a set for more efficient lookup + const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); + const [showOnlySelected, setShowOnlySelected] = useState(false); + return ( <> - { - typeaheadSubject.next(event.target.value); - }} - value={searchString} - /> + + + + updateSearchString(event.target.value)} + value={searchString} + /> + + + + dispatch(clearSelections({}))} + /> + + + + + setShowOnlySelected(!showOnlySelected)} + /> + + + + +
- {!loading && - availableOptions && - availableOptions.map((item, index) => ( - updateOption(index)} - > - {item.label} - - ))} - {loading && ( -
-
- - -

{OptionsListStrings.popover.getLoadingMessage()}

-
-
- )} + {!showOnlySelected && ( + <> + {availableOptions?.map((availableOption, index) => ( + { + if (selectedOptionsSet.has(availableOption)) { + dispatch(deselectOption(availableOption)); + return; + } + dispatch(selectOption(availableOption)); + }} + > + {availableOption} + + ))} + {loading && ( +
+
+
+ + +

{OptionsListStrings.popover.getLoadingMessage()}

+
+
+
+ )} - {!loading && (!availableOptions || availableOptions.length === 0) && ( -
-
- - -

{OptionsListStrings.popover.getEmptyMessage()}

-
-
+ {!loading && (!availableOptions || availableOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getEmptyMessage()}

+
+
+ )} + + )} + {showOnlySelected && ( + <> + {selectedOptions && + selectedOptions.map((availableOption, index) => ( + dispatch(deselectOption(availableOption))} + > + {availableOption} + + ))} + {(!selectedOptions || selectedOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getSelectionsEmptyMessage()}

+
+
+ )} + )}
diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts new file mode 100644 index 0000000000000..085175d002199 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts @@ -0,0 +1,37 @@ +/* + * 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 { PayloadAction } from '@reduxjs/toolkit'; +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { OptionsListEmbeddableInput } from './options_list_embeddable'; + +export const optionsListReducers = { + deselectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) return; + const itemIndex = state.selectedOptions.indexOf(action.payload); + if (itemIndex !== -1) { + const newSelections = [...state.selectedOptions]; + newSelections.splice(itemIndex, 1); + state.selectedOptions = newSelections; + } + }, + selectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) state.selectedOptions = []; + state.selectedOptions?.push(action.payload); + }, + clearSelections: (state: WritableDraft) => { + state.selectedOptions = []; + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index c07881020c9c2..9de00be688566 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -38,5 +38,28 @@ export const OptionsListStrings = { i18n.translate('presentationUtil.inputControls.optionsList.popover.empty', { defaultMessage: 'No filters found', }), + getSelectionsEmptyMessage: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.selectionsEmpty', { + defaultMessage: 'You have no selections', + }), + getSelectionButtonGroupLegend: () => + i18n.translate( + 'presentationUtil.inputControls.optionsList.popover.selectionButtonGroupLegend', + { + defaultMessage: 'Switch between showing selected options, and all available options', + } + ), + getAllOptionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.allOptionsTitle', { + defaultMessage: 'Show all options', + }), + getSelectedOptionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { + defaultMessage: 'Show selected options only', + }), + getClearAllSelectionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.clearAllSelectionsTitle', { + defaultMessage: 'Clear selections', + }), }, }; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts index 36ba1fcaa49b9..fd1e70cf38344 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts @@ -8,12 +8,15 @@ import { configureStore, EnhancedStore } from '@reduxjs/toolkit'; import { combineReducers, Reducer } from 'redux'; +import { enableMapSet } from 'immer'; export interface InjectReducerProps { key: string; asyncReducer: Reducer; } +enableMapSet(); + type ManagedEmbeddableReduxStore = EnhancedStore & { asyncReducers: { [key: string]: Reducer }; injectReducer: (props: InjectReducerProps) => void; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index a4912b5b5f2fc..d749af1cce00a 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -6,12 +6,18 @@ * Side Public License, v 1. */ -import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; +import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Draft } from 'immer/dist/types/types-external'; +import { debounceTime } from 'rxjs/operators'; import { isEqual } from 'lodash'; -import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { + ReduxContainerContextServices, + ReduxEmbeddableContextServices, + ReduxEmbeddableWrapperProps, +} from './types'; import { IEmbeddable, EmbeddableInput, @@ -19,11 +25,6 @@ import { IContainer, } from '../../../../embeddable/public'; import { getManagedEmbeddablesStore } from './generic_embeddable_store'; -import { - ReduxContainerContextServices, - ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperProps, -} from './types'; import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; const getDefaultProps = (): Required< @@ -139,18 +140,22 @@ const ReduxEmbeddableSync = state); const stateRef = useRef(currentState); - // When Embeddable Input changes, push differences to redux. useEffect(() => { - embeddable.getInput$().subscribe(() => { - const differences = diffInput(embeddable.getInput(), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - dispatch(updateEmbeddableReduxState(differences)); - } - }); + // When Embeddable Input changes, push differences to redux. + const inputSubscription = embeddable + .getInput$() + // .pipe(debounceTime(0)) // debounce input changes to ensure that when many updates are made in one render the latest wins out + .subscribe(() => { + const differences = diffInput(embeddable.getInput(), stateRef.current); + if (differences && Object.keys(differences).length > 0) { + dispatch(updateEmbeddableReduxState(differences)); + } + }); + return () => inputSubscription.unsubscribe(); }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); - // When redux state changes, push differences to Embeddable Input. useEffect(() => { + // When redux state changes, push differences to Embeddable Input. stateRef.current = currentState; const differences = diffInput(currentState, embeddable.getInput()); if (differences && Object.keys(differences).length > 0) { From bb3624d7c70e369c00796760d518cff17a0d2ccb Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 14 Oct 2021 20:40:39 -0400 Subject: [PATCH 02/13] remove unused import --- .../components/redux_embeddables/redux_embeddable_wrapper.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index d749af1cce00a..4a112f7d6e574 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -10,7 +10,6 @@ import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react- import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Draft } from 'immer/dist/types/types-external'; -import { debounceTime } from 'rxjs/operators'; import { isEqual } from 'lodash'; import { From a65eb4e96cdbec1524fad92b1cc9eeea8c85ae5d Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 14 Oct 2021 20:59:05 -0400 Subject: [PATCH 03/13] Code cleanups --- src/plugins/embeddable/public/lib/containers/container.ts | 1 - .../control_types/options_list/options_list_strings.ts | 7 ------- .../redux_embeddables/generic_embeddable_store.ts | 3 --- 3 files changed, 11 deletions(-) diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 928a187f36f00..06133fb2160c0 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -78,7 +78,6 @@ export abstract class Container< if (!this.input.panels[id]) { throw new PanelNotFoundError(); } - const panels = { panels: { ...this.input.panels, diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 9de00be688566..4422465b20c11 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -42,13 +42,6 @@ export const OptionsListStrings = { i18n.translate('presentationUtil.inputControls.optionsList.popover.selectionsEmpty', { defaultMessage: 'You have no selections', }), - getSelectionButtonGroupLegend: () => - i18n.translate( - 'presentationUtil.inputControls.optionsList.popover.selectionButtonGroupLegend', - { - defaultMessage: 'Switch between showing selected options, and all available options', - } - ), getAllOptionsButtonTitle: () => i18n.translate('presentationUtil.inputControls.optionsList.popover.allOptionsTitle', { defaultMessage: 'Show all options', diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts index fd1e70cf38344..36ba1fcaa49b9 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts @@ -8,15 +8,12 @@ import { configureStore, EnhancedStore } from '@reduxjs/toolkit'; import { combineReducers, Reducer } from 'redux'; -import { enableMapSet } from 'immer'; export interface InjectReducerProps { key: string; asyncReducer: Reducer; } -enableMapSet(); - type ManagedEmbeddableReduxStore = EnhancedStore & { asyncReducers: { [key: string]: Reducer }; injectReducer: (props: InjectReducerProps) => void; From 879b4eddab331559d774125066830959089c7118 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 14 Oct 2021 22:52:53 -0400 Subject: [PATCH 04/13] add single select functionality --- .../options_list/options_list_editor.tsx | 20 +++++++++++++++++-- .../options_list_popover_component.tsx | 17 +++++++++++++--- .../options_list/options_list_reducers.ts | 6 ++++++ .../options_list/options_list_strings.ts | 4 ++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx index b8c945ecb61cc..d8f70501a34ad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; +import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption, EuiSwitch } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; import { IFieldType, IIndexPattern } from '../../../../../../data/public'; @@ -26,6 +26,8 @@ interface OptionsListEditorProps extends ControlEditorProps { } interface OptionsListEditorState { + singleSelect?: boolean; + indexPatternSelectOptions: Array>; availableIndexPatterns?: { [key: string]: IIndexPattern }; indexPattern?: IIndexPattern; @@ -45,18 +47,25 @@ export const OptionsListEditor = ({ const [state, setState] = useState({ indexPattern: initialInput?.indexPattern, field: initialInput?.field, + singleSelect: initialInput?.singleSelect, indexPatternSelectOptions: [], fieldSelectOptions: [], }); const applySelection = ({ field, + singleSelect, indexPattern, }: { field?: IFieldType; + singleSelect?: boolean; indexPattern?: IIndexPattern; }) => { - const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) }; + const newState = { + ...(field ? { field } : {}), + ...(indexPattern ? { indexPattern } : {}), + ...(singleSelect !== undefined ? { singleSelect } : {}), + }; /** * apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable * input so that the same editor component can cover the 'create' use case. @@ -134,6 +143,13 @@ export const OptionsListEditor = ({ valueOfSelected={state.field?.name} /> + + applySelection({ singleSelect: !e.target.checked })} + /> + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 8e406f3e0249f..2a9b7e38c125d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { EuiFilterSelectItem, EuiLoadingChart, @@ -42,16 +42,23 @@ export const OptionsListPopover = ({ const { useEmbeddableSelector, useEmbeddableDispatch, - actions: { selectOption, deselectOption, clearSelections }, + actions: { selectOption, deselectOption, clearSelections, replaceSelection }, } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { selectedOptions } = useEmbeddableSelector((state) => state); + const { selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); // track selectedOptions in a set for more efficient lookup const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); const [showOnlySelected, setShowOnlySelected] = useState(false); + // remove all other selections if this control is single select + useEffect(() => { + if (singleSelect && selectedOptions && selectedOptions?.length > 1) { + dispatch(replaceSelection(selectedOptions[0])); + } + }, [selectedOptions, singleSelect, dispatch, replaceSelection]); + return ( <> @@ -110,6 +117,10 @@ export const OptionsListPopover = ({ checked={selectedOptionsSet?.has(availableOption) ? 'on' : undefined} key={index} onClick={() => { + if (singleSelect) { + dispatch(replaceSelection(availableOption)); + return; + } if (selectedOptionsSet.has(availableOption)) { dispatch(deselectOption(availableOption)); return; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts index 085175d002199..3e4104f62f914 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts @@ -31,6 +31,12 @@ export const optionsListReducers = { if (!state.selectedOptions) state.selectedOptions = []; state.selectedOptions?.push(action.payload); }, + replaceSelection: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.selectedOptions = [action.payload]; + }, clearSelections: (state: WritableDraft) => { state.selectedOptions = []; }, diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 4422465b20c11..6b5fcc0835cad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -28,6 +28,10 @@ export const OptionsListStrings = { i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), + getAllowMultiselectTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { + defaultMessage: 'Allow multiselect', + }), }, popover: { getLoadingMessage: () => From 5af6bd5d520093a9360cc22596ae4877b0d6e98c Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Fri, 15 Oct 2021 10:53:46 -0400 Subject: [PATCH 05/13] Move single select check to optionslist instead of popover --- .../options_list/options_list_component.tsx | 21 +++++++++++++------ .../options_list_popover_component.tsx | 9 +------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index ccd2d4841cc03..900570b38ca4d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -7,7 +7,7 @@ */ import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { Subject } from 'rxjs'; @@ -37,11 +37,13 @@ export const OptionsListComponent = ({ const [searchString, setSearchString] = useState(''); // Redux embeddable Context to get state from Embeddable input - const { useEmbeddableSelector } = useReduxEmbeddableContext< - OptionsListEmbeddableInput, - typeof optionsListReducers - >(); - const { twoLineLayout, selectedOptions } = useEmbeddableSelector((state) => state); + const { + useEmbeddableDispatch, + useEmbeddableSelector, + actions: { replaceSelection }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + const { twoLineLayout, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); // useStateObservable to get component state from Embeddable const { availableOptions, loading } = useStateObservable( @@ -51,6 +53,13 @@ export const OptionsListComponent = ({ } ); + // remove all other selections if this control is single select + useEffect(() => { + if (singleSelect && selectedOptions && selectedOptions?.length > 1) { + dispatch(replaceSelection(selectedOptions[0])); + } + }, [selectedOptions, singleSelect, dispatch, replaceSelection]); + const updateSearchString = useCallback( (newSearchString: string) => { typeaheadSubject.next(newSearchString); diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 2a9b7e38c125d..c178c32e6b9d4 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { EuiFilterSelectItem, EuiLoadingChart, @@ -52,13 +52,6 @@ export const OptionsListPopover = ({ const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); const [showOnlySelected, setShowOnlySelected] = useState(false); - // remove all other selections if this control is single select - useEffect(() => { - if (singleSelect && selectedOptions && selectedOptions?.length > 1) { - dispatch(replaceSelection(selectedOptions[0])); - } - }, [selectedOptions, singleSelect, dispatch, replaceSelection]); - return ( <> From 7dcb01cdd543cde2bae923ac20eac184c4a30923 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Fri, 15 Oct 2021 13:05:21 -0400 Subject: [PATCH 06/13] Update src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx Co-authored-by: Andrea Del Rio --- .../options_list/options_list_popover_component.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index c178c32e6b9d4..35dca40a26ab9 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -93,6 +93,7 @@ export const OptionsListPopover = ({ iconType="list" aria-pressed={showOnlySelected} color={showOnlySelected ? 'primary' : 'subdued'} + display={showOnlySelected ? 'base' : 'empty'} aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()} onClick={() => setShowOnlySelected(!showOnlySelected)} /> From b99d29618db30f93b7920ecaeb1e1f3d8d55e0a0 Mon Sep 17 00:00:00 2001 From: andreadelrio Date: Fri, 15 Oct 2021 16:01:41 -0700 Subject: [PATCH 07/13] add empty state and cleanup --- .../controls/__stories__/decorators.tsx | 4 +- .../component/control_frame_component.tsx | 6 +- .../component/control_group_component.tsx | 157 +++++++++++------- .../component/control_group_sortable_item.tsx | 42 +++-- .../controls/control_group/control_group.scss | 79 ++++++--- .../control_group/editor/create_control.tsx | 4 +- .../controls/control_group/opt_a.svg | 5 + 7 files changed, 187 insertions(+), 110 deletions(-) create mode 100644 src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx index c5d3cf2c815be..4ad5309ba604f 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx @@ -23,12 +23,10 @@ const panelStyle = { const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' }; -const inputBarStyle = { background: '#fff', padding: 4 }; - const layout = (OptionStory: Story) => ( KQL Bar - + diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 3c3876afb7763..4c308d39eeb36 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -89,13 +89,13 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con const form = ( {customPrepend ?? null} {usingTwoLineLayout ? undefined : ( - + {title} )} @@ -103,7 +103,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } >
{ const dispatch = useEmbeddableDispatch(); // current state - const { panels } = useEmbeddableSelector((state) => state); + const { panels, controlStyle } = useEmbeddableSelector((state) => state); const idsInOrder = useMemo( () => @@ -71,10 +79,10 @@ export const ControlGroup = () => { ); const [draggingId, setDraggingId] = useState(null); - const draggingIndex = useMemo( - () => (draggingId ? idsInOrder.indexOf(draggingId) : -1), - [idsInOrder, draggingId] - ); + const draggingIndex = useMemo(() => (draggingId ? idsInOrder.indexOf(draggingId) : -1), [ + idsInOrder, + draggingId, + ]); const sensors = useSensors( useSensor(PointerSensor), @@ -92,63 +100,94 @@ export const ControlGroup = () => { setDraggingId(null); }; + const emptyState = !(idsInOrder && idsInOrder.length > 0); + return ( - - - setDraggingId(active.id)} - onDragEnd={onDragEnd} - onDragCancel={() => setDraggingId(null)} - sensors={sensors} - collisionDetection={closestCenter} - layoutMeasuring={{ - strategy: LayoutMeasuringStrategy.Always, - }} - > - - - {idsInOrder.map( - (controlId, index) => - panels[controlId] && ( - - ) - )} - - - {draggingId ? : null} - - - - + + {idsInOrder.length > 0 ? ( + - - - openFlyout(forwardAllContext(, reduxContainerContext)) - } - /> - + setDraggingId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setDraggingId(null)} + sensors={sensors} + collisionDetection={closestCenter} + layoutMeasuring={{ + strategy: LayoutMeasuringStrategy.Always, + }} + > + + + {idsInOrder.map( + (controlId, index) => + panels[controlId] && ( + + ) + )} + + + + {draggingId ? : null} + + - - - - + + + + + + openFlyout(forwardAllContext(, reduxContainerContext)) + } + /> + + + + + + + + - - + ) : ( + <> + + + +

Controls let you filter and interact with your dashboard data

+
+
+ +
+ + Add control + +
+
+
+ + )} + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx index 5c222e3c130b5..5ea4cd394c147 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -32,11 +32,19 @@ export type SortableControlProps = ControlFrameProps & { */ export const SortableControl = (frameProps: SortableControlProps) => { const { embeddableId } = frameProps; - const { over, listeners, isSorting, transform, transition, attributes, isDragging, setNodeRef } = - useSortable({ - id: embeddableId, - animateLayoutChanges: () => true, - }); + const { + over, + listeners, + isSorting, + transform, + transition, + attributes, + isDragging, + setNodeRef, + } = useSortable({ + id: embeddableId, + animateLayoutChanges: () => true, + }); frameProps.dragInfo = { ...frameProps.dragInfo, isOver: over?.id === embeddableId, isDragging }; @@ -74,13 +82,13 @@ const SortableControlInner = forwardRef< return ( (draggingIndex ?? -1), + className={classNames('controlFrame__wrapper', { + 'controlFrame__wrapper-isDragging': isDragging, + 'controlFrame__wrapper--small': width === 'small', + 'controlFrame__wrapper--medium': width === 'medium', + 'controlFrame__wrapper--large': width === 'large', + 'controlFrame__wrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1), + 'controlFrame__wrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1), })} style={style} > @@ -106,11 +114,11 @@ export const ControlClone = ({ draggingId }: { draggingId: string }) => { const title = panels[draggingId].explicitInput.title; return ( {controlStyle === 'twoLine' ? {title} : undefined} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss index f49efa7aab043..b309590fcb769 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -1,35 +1,62 @@ $smallControl: $euiSize * 14; $mediumControl: $euiSize * 25; $largeControl: $euiSize * 50; -$controlMinWidth: $euiSize * 14; +$controlMinWidth: $euiSize * 16; .controlGroup { - margin-left: $euiSizeXS; overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear. min-height: $euiSize * 4; - padding: $euiSize 0; } -.controlFrame--cloneWrapper { +.controlsWrapper { + &--empty { + display: flex; + @include euiBreakpoint('m', 'l', 'xl') { + background: url(opt_a.svg); + background-position: left top; + background-repeat: no-repeat; + .addControlButton { + text-align: center; + } + .emptyStateText { + padding-left: $euiSize * 2; + } + } + @include euiBreakpoint('xs', 's') { + .addControlButton { + text-align: center; + } + } + padding-right: $euiSizeL; + } + + &--twoLine { + .groupEditActions { + padding-top: $euiSize; + } + } +} + +.controlFrame__cloneWrapper { width: max-content; .euiFormLabel { padding-bottom: $euiSizeXS; } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-twoLine { + &--twoLine { margin-top: -$euiSize * 1.25; } @@ -49,7 +76,7 @@ $controlMinWidth: $euiSize * 14; min-width: $controlMinWidth; } - .controlFrame--formControlLayout, .controlFrame--draggable { + .controlFrame__formControlLayout, .controlFrame--draggable { &-clone { box-shadow: 0 0 0 1px $euiShadowColor, 0 1px 6px 0 $euiShadowColor; @@ -62,28 +89,28 @@ $controlMinWidth: $euiSize * 14; } } -.controlFrame--wrapper { +.controlFrame__wrapper { flex-basis: auto; position: relative; display: block; - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { width: 100%; min-width: $controlMinWidth; transition:background-color .1s, color .1s; - &__label { + &Label { @include euiTextTruncate; max-width: 50%; } - &:not(.controlFrame--formControlLayout-clone) { + &:not(.controlFrame__formControlLayout-clone) { .controlFrame--dragHandle { cursor: grab; } } - .controlFrame--control { + .controlFrame__control { height: 100%; transition: opacity .1s; @@ -93,21 +120,21 @@ $controlMinWidth: $euiSize * 14; } } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-insertBefore, - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertBefore, + &--insertAfter { + .controlFrame__formControlLayout:after { content: ''; position: absolute; background-color: transparentize($euiColorPrimary, .5); @@ -118,14 +145,14 @@ $controlMinWidth: $euiSize * 14; } } - &-insertBefore { - .controlFrame--formControlLayout:after { + &--insertBefore { + .controlFrame__formControlLayout:after { left: -$euiSizeS; } } - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertAfter { + .controlFrame__formControlLayout:after { right: -$euiSizeS; } } @@ -167,7 +194,7 @@ $controlMinWidth: $euiSize * 14; .euiFormRow__labelWrapper { opacity: 0; } - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { background-color: $euiColorEmptyShade !important; color: transparent !important; box-shadow: none; @@ -176,7 +203,7 @@ $controlMinWidth: $euiSize * 14; opacity: 0; } - .controlFrame--control { + .controlFrame__control { opacity: 0; } } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx index 9f59fe98cc0c1..9af697e6f4fbd 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx @@ -112,8 +112,8 @@ export const CreateControlButton = () => { if (getInputControlTypes().length === 0) return null; const commonButtonProps = { - iconType: 'plus', - color: 'text' as EuiButtonIconColor, + iconType: 'plusInCircle', + color: 'primary' as EuiButtonIconColor, 'data-test-subj': 'inputControlsSortingButton', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg new file mode 100644 index 0000000000000..6722db6f26a55 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg @@ -0,0 +1,5 @@ + + + + + From 324bf11b86eb6bcf13866072f95b6e98435da953 Mon Sep 17 00:00:00 2001 From: andreadelrio Date: Fri, 15 Oct 2021 16:27:30 -0700 Subject: [PATCH 08/13] remove setAllWidths button --- .../editor/edit_control_group.tsx | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 9438091e2fb1d..8e6211f9d6ded 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiTitle, EuiSpacer, @@ -17,6 +17,7 @@ import { EuiButtonGroup, EuiButtonEmpty, EuiFlyoutHeader, + EuiCheckbox, } from '@elastic/eui'; import { @@ -44,6 +45,11 @@ export const EditControlGroup = () => { const dispatch = useEmbeddableDispatch(); const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); + const [checked, setChecked] = useState(false); + + const onChange = (e) => { + setChecked(e.target.checked); + }; return ( <> @@ -66,34 +72,24 @@ export const EditControlGroup = () => { - - - - dispatch(setDefaultControlWidth(newWidth as ControlWidth)) - } - /> - - - - dispatch(setAllControlWidths(defaultControlWidth ?? DEFAULT_CONTROL_WIDTH)) - } - aria-label={'delete-all'} - iconType="returnKey" - size="s" - > - {ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()} - - - + + dispatch(setDefaultControlWidth(newWidth as ControlWidth)) + } + /> - - + + onChange(e)} + /> + { From 7c5ba0c76fd3a049b069d6f91204ca35e8a26531 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Mon, 18 Oct 2021 21:08:49 -0400 Subject: [PATCH 09/13] fix types, update copy to match suggestions --- .../component/control_group_component.tsx | 28 +++---- .../component/control_group_sortable_item.tsx | 42 +++++------ .../controls/control_group/control_group.scss | 6 +- .../control_group/control_group_strings.ts | 52 ++++++++----- .../control_group/editor/control_editor.tsx | 12 ++- .../control_group/editor/create_control.tsx | 45 +++++++---- .../control_group/editor/edit_control.tsx | 1 + .../editor/edit_control_group.tsx | 74 ++++++++++++++++--- .../options_list/options_list_strings.ts | 4 +- 9 files changed, 174 insertions(+), 90 deletions(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx index 44b4bcdc5ca28..77745d78ef479 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -9,7 +9,6 @@ import '../control_group.scss'; import { - EuiButton, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, @@ -79,10 +78,10 @@ export const ControlGroup = () => { ); const [draggingId, setDraggingId] = useState(null); - const draggingIndex = useMemo(() => (draggingId ? idsInOrder.indexOf(draggingId) : -1), [ - idsInOrder, - draggingId, - ]); + const draggingIndex = useMemo( + () => (draggingId ? idsInOrder.indexOf(draggingId) : -1), + [idsInOrder, draggingId] + ); const sensors = useSensors( useSensor(PointerSensor), @@ -156,15 +155,20 @@ export const ControlGroup = () => { iconType="gear" color="subdued" data-test-subj="inputControlsSortingButton" - onClick={() => - openFlyout(forwardAllContext(, reduxContainerContext)) - } + onClick={() => { + const flyoutInstance = openFlyout( + forwardAllContext( + flyoutInstance.close()} />, + reduxContainerContext + ) + ); + }} /> - + @@ -175,14 +179,12 @@ export const ControlGroup = () => { -

Controls let you filter and interact with your dashboard data

+

{ControlGroupStrings.emptyState.getCallToAction()}

- - Add control - +
diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx index 5ea4cd394c147..e3a00ca221087 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -32,19 +32,11 @@ export type SortableControlProps = ControlFrameProps & { */ export const SortableControl = (frameProps: SortableControlProps) => { const { embeddableId } = frameProps; - const { - over, - listeners, - isSorting, - transform, - transition, - attributes, - isDragging, - setNodeRef, - } = useSortable({ - id: embeddableId, - animateLayoutChanges: () => true, - }); + const { over, listeners, isSorting, transform, transition, attributes, isDragging, setNodeRef } = + useSortable({ + id: embeddableId, + animateLayoutChanges: () => true, + }); frameProps.dragInfo = { ...frameProps.dragInfo, isOver: over?.id === embeddableId, isDragging }; @@ -82,13 +74,13 @@ const SortableControlInner = forwardRef< return ( (draggingIndex ?? -1), + className={classNames('controlFrameWrapper', { + 'controlFrameWrapper-isDragging': isDragging, + 'controlFrameWrapper--small': width === 'small', + 'controlFrameWrapper--medium': width === 'medium', + 'controlFrameWrapper--large': width === 'large', + 'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1), + 'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1), })} style={style} > @@ -114,11 +106,11 @@ export const ControlClone = ({ draggingId }: { draggingId: string }) => { const title = panels[draggingId].explicitInput.title; return ( {controlStyle === 'twoLine' ? {title} : undefined} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss index b309590fcb769..b216523df5b0d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -1,7 +1,7 @@ $smallControl: $euiSize * 14; $mediumControl: $euiSize * 25; $largeControl: $euiSize * 50; -$controlMinWidth: $euiSize * 16; +$controlMinWidth: $euiSize * 14; .controlGroup { overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear. @@ -37,7 +37,7 @@ $controlMinWidth: $euiSize * 16; } } -.controlFrame__cloneWrapper { +.controlFrameCloneWrapper { width: max-content; .euiFormLabel { @@ -89,7 +89,7 @@ $controlMinWidth: $euiSize * 16; } } -.controlFrame__wrapper { +.controlFrameWrapper { flex-basis: auto; position: relative; display: block; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 35e490b0ea530..2bd497f93bc95 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -13,10 +13,27 @@ export const ControlGroupStrings = { i18n.translate('presentationUtil.inputControls.controlGroup.title', { defaultMessage: 'Control group', }), + emptyState: { + getCallToAction: () => + i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.callToAction', { + defaultMessage: 'Controls let you filter and interact with your dashboard data', + }), + getAddControlButtonTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.emptyState.addControlButtonTitle', + { + defaultMessage: 'Add control', + } + ), + }, manageControl: { - getFlyoutTitle: () => + getFlyoutCreateTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { + defaultMessage: 'Create control', + }), + getFlyoutEditTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { - defaultMessage: 'Manage control', + defaultMessage: 'Edit control', }), getTitleInputTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { @@ -24,7 +41,7 @@ export const ControlGroupStrings = { }), getWidthInputTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { - defaultMessage: 'Control width', + defaultMessage: 'Control size', }), getSaveChangesTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', { @@ -42,15 +59,15 @@ export const ControlGroupStrings = { }), getManageButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getFlyoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getDefaultWidthTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { - defaultMessage: 'Default width', + defaultMessage: 'Default size', }), getLayoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { @@ -62,7 +79,7 @@ export const ControlGroupStrings = { }), getSetAllWidthsToDefaultTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { - defaultMessage: 'Set all widths to default', + defaultMessage: 'Set all sizes to default', }), getDeleteAllButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { @@ -73,7 +90,7 @@ export const ControlGroupStrings = { i18n.translate( 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', { - defaultMessage: 'Change control width', + defaultMessage: 'Change control size', } ), getAutoWidthTitle: () => @@ -103,11 +120,11 @@ export const ControlGroupStrings = { ), getSingleLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line layout', + defaultMessage: 'Single line', }), getTwoLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Two line layout', + defaultMessage: 'Double line', }), }, deleteControls: { @@ -141,16 +158,15 @@ export const ControlGroupStrings = { discardChanges: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard changes?', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { - defaultMessage: - 'Discard changes to this control? Changes are not recoverable once discardsd.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard changes', }), getCancel: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { @@ -160,15 +176,15 @@ export const ControlGroupStrings = { discardNewControl: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard new control', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { - defaultMessage: 'Discard new control? Controls are not recoverable once discarded.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard control', }), getCancel: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { @@ -179,7 +195,7 @@ export const ControlGroupStrings = { floatingActions: { getEditButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { - defaultMessage: 'Manage control', + defaultMessage: 'Edit control', }), getRemoveButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx index 38d8faf37397a..30faa7d478522 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx @@ -37,6 +37,7 @@ import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; interface ManageControlProps { title?: string; + isCreate: boolean; onSave: () => void; width: ControlWidth; onCancel: () => void; @@ -51,6 +52,7 @@ export const ControlEditor = ({ removeControl, updateTitle, updateWidth, + isCreate, onCancel, onSave, title, @@ -68,7 +70,11 @@ export const ControlEditor = ({ <> -

{ControlGroupStrings.manageControl.getFlyoutTitle()}

+

+ {isCreate + ? ControlGroupStrings.manageControl.getFlyoutCreateTitle() + : ControlGroupStrings.manageControl.getFlyoutEditTitle()} +

@@ -121,7 +127,7 @@ export const ControlEditor = ({ { onCancel(); @@ -132,7 +138,7 @@ export const ControlEditor = ({ { +export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => { // Presentation Services Context const { overlays, controls } = pluginServices.getHooks(); const { getInputControlTypes, getControlFactory } = controls.useService(); @@ -49,7 +50,7 @@ export const CreateControlButton = () => { // current state const { defaultControlWidth } = useEmbeddableSelector((state) => state); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false); const createNewControl = async (type: string) => { const factory = getControlFactory(type); @@ -80,6 +81,7 @@ export const CreateControlButton = () => { const flyoutInstance = openFlyout( forwardAllContext( (inputToReturn.title = newTitle)} updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} @@ -114,10 +116,31 @@ export const CreateControlButton = () => { const commonButtonProps = { iconType: 'plusInCircle', color: 'primary' as EuiButtonIconColor, - 'data-test-subj': 'inputControlsSortingButton', + 'data-test-subj': 'controlsCreateButton', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; + const onCreateButtonClick = () => { + if (getInputControlTypes().length > 1) { + setIsControlTypePopoverOpen(!isControlTypePopoverOpen); + return; + } + createNewControl(getInputControlTypes()[0]); + }; + + const createControlButton = isIconButton ? ( + + ) : ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ); + if (getInputControlTypes().length > 1) { const items: ReactElement[] = []; getInputControlTypes().forEach((type) => { @@ -127,7 +150,7 @@ export const CreateControlButton = () => { key={type} icon={factory.getIconType?.()} onClick={() => { - setIsPopoverOpen(false); + setIsControlTypePopoverOpen(false); createNewControl(type); }} > @@ -135,24 +158,18 @@ export const CreateControlButton = () => { ); }); - const button = setIsPopoverOpen(true)} />; return ( setIsPopoverOpen(false)} + closePopover={() => setIsControlTypePopoverOpen(false)} > ); } - return ( - createNewControl(getInputControlTypes()[0])} - /> - ); + return createControlButton; }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx index db15db71fd996..891d83569b08b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -88,6 +88,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const flyoutInstance = openFlyout( forwardAllContext( { diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 8e6211f9d6ded..7904de37c6366 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -18,6 +18,8 @@ import { EuiButtonEmpty, EuiFlyoutHeader, EuiCheckbox, + EuiFlyoutFooter, + EuiButton, } from '@elastic/eui'; import { @@ -32,7 +34,13 @@ import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; -export const EditControlGroup = () => { +interface EditControlGroupState { + newControlStyle: ControlGroupInput['controlStyle']; + newDefaultWidth: ControlGroupInput['defaultControlWidth']; + setAllWidths: boolean; +} + +export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) => { const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); @@ -42,13 +50,27 @@ export const EditControlGroup = () => { useEmbeddableDispatch, actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth }, } = useReduxContainerContext(); - const dispatch = useEmbeddableDispatch(); const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); - const [checked, setChecked] = useState(false); - const onChange = (e) => { - setChecked(e.target.checked); + const [state, setState] = useState({ + newControlStyle: controlStyle, + newDefaultWidth: defaultControlWidth, + setAllWidths: false, + }); + + const onSave = () => { + const { newControlStyle, newDefaultWidth, setAllWidths } = state; + if (newControlStyle && newControlStyle !== controlStyle) { + dispatch(setControlStyle(newControlStyle)); + } + if (newDefaultWidth && newDefaultWidth !== defaultControlWidth) { + dispatch(setDefaultControlWidth(newDefaultWidth)); + } + if (setAllWidths && newDefaultWidth) { + dispatch(setAllControlWidths(newDefaultWidth)); + } + closeFlyout(); }; return ( @@ -64,9 +86,9 @@ export const EditControlGroup = () => { color="primary" legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()} options={CONTROL_LAYOUT_OPTIONS} - idSelected={controlStyle} + idSelected={state.newControlStyle} onChange={(newControlStyle) => - dispatch(setControlStyle(newControlStyle as ControlStyle)) + setState((s) => ({ ...s, newControlStyle: newControlStyle as ControlStyle })) } /> @@ -74,11 +96,11 @@ export const EditControlGroup = () => { - dispatch(setDefaultControlWidth(newWidth as ControlWidth)) + onChange={(newDefaultWidth: string) => + setState((s) => ({ ...s, newDefaultWidth: newDefaultWidth as ControlWidth })) } /> @@ -86,14 +108,15 @@ export const EditControlGroup = () => { onChange(e)} + checked={state.setAllWidths} + onChange={(e) => setState((s) => ({ ...s, setAllWidths: e.target.checked }))} /> { if (!containerActions?.removeEmbeddable) return; + closeFlyout(); openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), @@ -115,6 +138,33 @@ export const EditControlGroup = () => { {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + + { + closeFlyout(); + }} + > + {ControlGroupStrings.manageControl.getCancelTitle()} + + + + { + onSave(); + }} + > + {ControlGroupStrings.manageControl.getSaveChangesTitle()} + + + + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 6b5fcc0835cad..40828f9e335f2 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -30,7 +30,7 @@ export const OptionsListStrings = { }), getAllowMultiselectTitle: () => i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { - defaultMessage: 'Allow multiselect', + defaultMessage: 'Allow multiple selections in dropdown', }), }, popover: { @@ -52,7 +52,7 @@ export const OptionsListStrings = { }), getSelectedOptionsButtonTitle: () => i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { - defaultMessage: 'Show selected options only', + defaultMessage: 'Show only selected options', }), getClearAllSelectionsButtonTitle: () => i18n.translate('presentationUtil.inputControls.optionsList.popover.clearAllSelectionsTitle', { From a7a0fbf2c85f41377346d8f5fb52b094ab425db0 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 19 Oct 2021 10:56:56 -0400 Subject: [PATCH 10/13] fix i18n --- .../controls/control_group/control_group_strings.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 2bd497f93bc95..657add5ef048f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -28,11 +28,14 @@ export const ControlGroupStrings = { }, manageControl: { getFlyoutCreateTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { - defaultMessage: 'Create control', - }), + i18n.translate( + 'presentationUtil.inputControls.controlGroup.manageControl.createFlyoutTitle', + { + defaultMessage: 'Create control', + } + ), getFlyoutEditTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.editFlyoutTitle', { defaultMessage: 'Edit control', }), getTitleInputTitle: () => From f6602c6d2fdc6e4ef5c697fce5da1383e6713b51 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 19 Oct 2021 11:21:09 -0400 Subject: [PATCH 11/13] changed to more specific id --- .../controls/control_group/editor/edit_control_group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 7904de37c6366..7536e1ed33b26 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -106,7 +106,7 @@ export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) = setState((s) => ({ ...s, setAllWidths: e.target.checked }))} From 66b358243bb25c9b01534319f7465429f4f52eda Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Mon, 18 Oct 2021 18:35:21 -0400 Subject: [PATCH 12/13] Create filters when controls change. Integrated controls on dashboard behind lab --- .../embeddable/dashboard_container.tsx | 35 ++- .../viewport/dashboard_viewport.tsx | 23 +- .../lib/convert_dashboard_state.ts | 19 +- .../lib/dashboard_control_group.ts | 194 ++++++++++++++++ .../application/lib/diff_dashboard_state.ts | 6 +- .../public/application/lib/save_dashboard.ts | 4 + .../lib/sync_dashboard_container_input.ts | 12 +- .../lib/sync_dashboard_index_patterns.ts | 10 +- .../state/dashboard_state_slice.ts | 8 + .../saved_dashboards/saved_dashboard.ts | 11 + src/plugins/dashboard/public/types.ts | 4 + .../server/saved_objects/dashboard.ts | 6 + .../public/lib/containers/container.ts | 1 - src/plugins/presentation_util/common/labs.ts | 17 +- src/plugins/presentation_util/kibana.json | 2 +- .../__stories__/controls_service_stub.ts | 29 --- .../controls/__stories__/flights.ts | 59 ----- .../__stories__/input_controls.stories.tsx | 43 +--- .../storybook_control_factories.ts | 21 +- .../component/control_frame_component.tsx | 32 ++- .../component/control_group_component.tsx | 2 +- .../controls/control_group/control_group.scss | 7 + .../control_group/control_group_strings.ts | 88 ++++---- .../control_group/editor/create_control.tsx | 21 +- .../control_group/editor/edit_control.tsx | 5 +- .../embeddable/control_group_container.tsx | 98 ++++++-- .../control_group_container_factory.ts | 8 +- .../controls/control_group/index.ts | 12 + .../controls/control_group/types.ts | 29 ++- .../controls/control_types/index.ts | 9 + .../control_types/options_list/index.ts | 2 +- .../options_list/options_list.scss | 14 -- .../options_list/options_list_component.tsx | 24 +- .../options_list/options_list_editor.tsx | 105 ++++----- .../options_list/options_list_embeddable.tsx | 209 +++++++++++++----- .../options_list_embeddable_factory.tsx | 19 +- .../options_list_popover_component.tsx | 12 - .../options_list/options_list_strings.ts | 29 ++- .../components/controls/controls_service.ts | 19 +- .../controls/hooks/use_child_embeddable.ts | 6 +- .../controls/hooks/use_state_observable.ts | 2 +- .../public/components/controls/index.ts | 11 + .../public/components/controls/types.ts | 46 +++- .../generic_embeddable_store.ts | 2 +- .../redux_embeddable_wrapper.tsx | 43 +++- src/plugins/presentation_util/public/index.ts | 2 + .../presentation_util/public/plugin.ts | 36 ++- .../public/services/controls.ts | 64 ++---- .../presentation_util/public/services/data.ts | 13 ++ .../public/services/data_views.ts | 14 ++ .../public/services/index.ts | 4 + .../public/services/kibana/data.ts | 25 +++ .../public/services/kibana/data_views.ts | 27 +++ .../public/services/kibana/index.ts | 18 +- .../public/services/storybook/data.ts | 19 ++ .../public/services/storybook/data_views.ts | 27 +++ .../services/storybook/fixtures/flights.ts | 85 +++++++ .../storybook/fixtures/flights_data.ts} | 0 .../public/services/storybook/index.ts | 4 + .../public/services/stub/index.ts | 5 + src/plugins/presentation_util/public/types.ts | 19 +- src/plugins/presentation_util/tsconfig.json | 1 + 62 files changed, 1193 insertions(+), 528 deletions(-) create mode 100644 src/plugins/dashboard/public/application/lib/dashboard_control_group.ts delete mode 100644 src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts delete mode 100644 src/plugins/presentation_util/public/components/controls/__stories__/flights.ts create mode 100644 src/plugins/presentation_util/public/components/controls/control_group/index.ts create mode 100644 src/plugins/presentation_util/public/components/controls/control_types/index.ts create mode 100644 src/plugins/presentation_util/public/components/controls/index.ts create mode 100644 src/plugins/presentation_util/public/services/data.ts create mode 100644 src/plugins/presentation_util/public/services/data_views.ts create mode 100644 src/plugins/presentation_util/public/services/kibana/data.ts create mode 100644 src/plugins/presentation_util/public/services/kibana/data_views.ts create mode 100644 src/plugins/presentation_util/public/services/storybook/data.ts create mode 100644 src/plugins/presentation_util/public/services/storybook/data_views.ts create mode 100644 src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts rename src/plugins/presentation_util/public/{components/fixtures/flights.ts => services/storybook/fixtures/flights_data.ts} (100%) diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 86f81aa1ee10d..2ab7bcac754e4 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -39,6 +39,11 @@ import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { DashboardAppCapabilities, DashboardContainerInput } from '../../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; +import { + combineDashboardFiltersWithControlGroupFilters, + createAndSyncDashboardControlGroup, +} from '../lib/dashboard_control_group'; +import { ControlGroupContainer } from '../../../../presentation_util/public'; export interface DashboardContainerServices { ExitFullScreenButton: React.ComponentType; @@ -88,6 +93,9 @@ const defaultCapabilities: DashboardAppCapabilities = { export class DashboardContainer extends Container { public readonly type = DASHBOARD_CONTAINER_TYPE; + private onDestroyControlGroup?: () => void; + + public controlGroup?: ControlGroupContainer; public getPanelCount = () => { return Object.keys(this.getInput().panels).length; }; @@ -106,6 +114,20 @@ export class DashboardContainer extends Container { + if (!result) return; + const { controlGroup, onDestroyControlGroup } = result; + this.controlGroup = controlGroup; + this.onDestroyControlGroup = onDestroyControlGroup; + } + ); + } } protected createNewPanelState< @@ -232,7 +254,7 @@ export class DashboardContainer extends Container - + , @@ -240,6 +262,11 @@ export class DashboardContainer extends Container { static contextType = context; - public readonly context!: DashboardReactContextValue; + + private controlsRoot: React.RefObject; + private subscription?: Subscription; private mounted: boolean = false; constructor(props: DashboardViewportProps) { @@ -38,7 +43,10 @@ export class DashboardViewport extends React.Component this.setState({ controlGroupReady: true })); + } } public componentWillUnmount() { @@ -83,7 +97,8 @@ export class DashboardViewport extends React.Component + <> +
)} - + {this.state.controlGroupReady && }
- + ); } } diff --git a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts index 123ef381f25f6..e0b528f0f9952 100644 --- a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts @@ -20,6 +20,8 @@ import { DashboardBuildContext, } from '../../types'; import { convertSavedPanelsToPanelMap } from './convert_saved_panels_to_panel_map'; +import { deserializeControlGroupFromDashboardSavedObject } from './dashboard_control_group'; +import { ControlGroupInput } from '../../../../presentation_util/public'; interface SavedObjectToDashboardStateProps { version: string; @@ -73,6 +75,9 @@ export const savedObjectToDashboardState = ({ usageCollection ); + rawState.controlGroupInput = deserializeControlGroupFromDashboardSavedObject( + savedDashboard + ) as ControlGroupInput; return { ...rawState, panels: convertSavedPanelsToPanelMap(rawState.panels) }; }; @@ -91,8 +96,17 @@ export const stateToDashboardContainerInput = ({ const { filterManager, timefilter: timefilterService } = queryService; const { timefilter } = timefilterService; - const { expandedPanelId, fullScreenMode, description, options, viewMode, panels, query, title } = - dashboardState; + const { + controlGroupInput, + expandedPanelId, + fullScreenMode, + description, + options, + viewMode, + panels, + query, + title, + } = dashboardState; return { refreshConfig: timefilter.getRefreshInterval(), @@ -102,6 +116,7 @@ export const stateToDashboardContainerInput = ({ dashboardCapabilities, isEmbeddedExternally, ...(options || {}), + controlGroupInput, searchSessionId, expandedPanelId, description, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts new file mode 100644 index 0000000000000..bd04c4d746130 --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -0,0 +1,194 @@ +/* + * 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 uuid from 'uuid'; +import { Subscription } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; + +import { compareFilters, COMPARE_ALL_OPTIONS, Filter } from '@kbn/es-query'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { + ControlGroupContainer, + ControlGroupInput, + ControlGroupOutput, + ControlStyle, + CONTROL_GROUP_TYPE, +} from '../../../../presentation_util/public'; +import { DashboardContainer } from '..'; +import { EmbeddableStart, isErrorEmbeddable } from '../../services/embeddable'; +import { DashboardContainerInput, DashboardSavedObject } from '../..'; +import { DashboardState } from '../../types'; + +// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. +export interface DashboardControlGroupInput { + panels: ControlGroupInput['panels']; + controlStyle: ControlGroupInput['controlStyle']; +} + +export const getDefaultDashboardControlGroupInput = () => ({ + controlStyle: 'oneLine' as ControlStyle, + panels: {}, +}); + +export const createAndSyncDashboardControlGroup = async ({ + dashboardContainer, + getEmbeddableFactory, +}: { + dashboardContainer: DashboardContainer; + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; +}) => { + const subscriptions = new Subscription(); + const controlsGroupFactory = getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + >(CONTROL_GROUP_TYPE); + const controlGroup = await controlsGroupFactory?.create({ + ...getDefaultDashboardControlGroupInput(), + ...(dashboardContainer.getInput().controlGroupInput ?? {}), + id: uuid.v4(), + }); + if (!controlGroup || isErrorEmbeddable(controlGroup)) return; + + const isControlGroupInputEqual = () => + controlGroupInputIsEqual( + controlGroup.getInput(), + dashboardContainer.getInput().controlGroupInput + ); + + // Because dashboard container stores control group state, any control group changes need to be passed to dashboard container + subscriptions.add( + controlGroup.getInput$().subscribe(() => { + const { panels, controlStyle } = controlGroup.getInput(); + if (!isControlGroupInputEqual()) { + dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + } + }) + ); + + subscriptions.add( + dashboardContainer + .getInput$() + .pipe( + // skip updates when nothing of interest has changed. This prevents changes in lastReloadRequestTime from overwriting control group input + distinctUntilChanged( + (a, b) => + !( + ['controlGroupInput', 'filters', 'timeRange', 'query'] as Array< + keyof DashboardContainerInput + > + ) + .map((key) => deepEqual(a[key], b[key])) + .includes(false) + ) + ) + .subscribe(() => { + let newInput: Partial = {}; + if (!isControlGroupInputEqual()) { + newInput = { ...dashboardContainer.getInput().controlGroupInput }; + } + // pass filters, query and time range down from + if ( + !compareFilters( + controlGroup.getInput().filters || [], + dashboardContainer.getInput().filters || [], + COMPARE_ALL_OPTIONS + ) + ) { + newInput.filters = dashboardContainer.getInput().filters; + } + if ( + !deepEqual(controlGroup.getInput().timeRange, dashboardContainer.getInput().timeRange) + ) { + newInput.timeRange = dashboardContainer.getInput().timeRange; + } + if (!deepEqual(controlGroup.getInput().query, dashboardContainer.getInput().query)) { + newInput.query = dashboardContainer.getInput().query; + } + if (Object.keys(newInput).length > 0) { + controlGroup.updateInput(newInput); + } + }) + ); + + // when control group outputs filters, force a refresh! + subscriptions.add( + controlGroup + .getOutput$() + .subscribe(() => dashboardContainer.updateInput({ lastReloadRequestTime: Date.now() })) + ); + + return { onDestroyControlGroup: () => subscriptions.unsubscribe(), controlGroup }; +}; + +export const controlGroupInputIsEqual = ( + a: DashboardControlGroupInput | undefined, + b: DashboardControlGroupInput | undefined +) => { + const defaultInput = getDefaultDashboardControlGroupInput(); + const inputA = { + panels: a?.panels ?? defaultInput.panels, + controlStyle: a?.controlStyle ?? defaultInput.controlStyle, + }; + const inputB = { + panels: b?.panels ?? defaultInput.panels, + controlStyle: b?.controlStyle ?? defaultInput.controlStyle, + }; + if (deepEqual(inputA, inputB)) return true; + return false; +}; + +export const serializeControlGroupToDashboardSavedObject = ( + dashboardSavedObject: DashboardSavedObject, + dashboardState: DashboardState +) => { + // only save to saved object if control group is not default + if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) return; + dashboardSavedObject.controlGroupInput = { + controlStyle: dashboardState.controlGroupInput?.controlStyle, + panelsJSON: JSON.stringify(dashboardState.controlGroupInput?.panels), + }; +}; + +export const deserializeControlGroupFromDashboardSavedObject = ( + dashboardSavedObject: DashboardSavedObject +): Omit | undefined => { + if (!dashboardSavedObject.controlGroupInput) return; + + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: + dashboardSavedObject.controlGroupInput?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: dashboardSavedObject.controlGroupInput?.panelsJSON + ? JSON.parse(dashboardSavedObject.controlGroupInput?.panelsJSON) + : {}, + }; +}; + +export const combineDashboardFiltersWithControlGroupFilters = ( + dashboardFilters: Filter[], + controlGroup: ControlGroupContainer +) => { + const dashboardFiltersByKey = dashboardFilters.reduce( + (acc: { [key: string]: Filter }, current) => { + const key = current.meta.key; + if (key) acc[key] = current; + return acc; + }, + {} + ); + const controlGroupFiltersByKey = controlGroup + .getOutput() + .filters?.reduce((acc: { [key: string]: Filter }, current) => { + const key = current.meta.key; + if (key) acc[key] = current; + return acc; + }, {}); + const finalFilters = { ...dashboardFiltersByKey, ...(controlGroupFiltersByKey ?? {}) }; + return Object.values(finalFilters); +}; diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts index e718c98cb3626..2e89ee70d057d 100644 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts @@ -15,6 +15,7 @@ import { DashboardPanelMap, DashboardState, } from '../../types'; +import { controlGroupInputIsEqual } from './dashboard_control_group'; interface DashboardDiffCommon { [key: string]: unknown; @@ -40,7 +41,7 @@ export const diffDashboardState = ( const common = commonDiffFilters( original as unknown as DashboardDiffCommonFilters, newState as unknown as DashboardDiffCommonFilters, - ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId'], + ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId', 'controlGroupInput'], true ); @@ -48,6 +49,9 @@ export const diffDashboardState = ( ...common, ...(panelsAreEqual(original.panels, newState.panels) ? {} : { panels: newState.panels }), ...(optionsAreEqual(original.options, newState.options) ? {} : { options: newState.options }), + ...(controlGroupInputIsEqual(original.controlGroupInput, newState.controlGroupInput) + ? {} + : { controlGroupInput: newState.controlGroupInput }), }; }; diff --git a/src/plugins/dashboard/public/application/lib/save_dashboard.ts b/src/plugins/dashboard/public/application/lib/save_dashboard.ts index 960d7d9cc8687..5a699eb116401 100644 --- a/src/plugins/dashboard/public/application/lib/save_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/save_dashboard.ts @@ -19,6 +19,7 @@ import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data'; import { convertPanelStateToSavedDashboardPanel } from '../../../common/embeddable/embeddable_saved_object_converters'; import { DashboardSessionStorage } from './dashboard_session_storage'; +import { serializeControlGroupToDashboardSavedObject } from './dashboard_control_group'; export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { stayInEditMode?: boolean }; @@ -60,6 +61,9 @@ export const saveDashboard = async ({ savedDashboard.optionsJSON = JSON.stringify(options); savedDashboard.panelsJSON = JSON.stringify(savedDashboardPanels); + // control group input + serializeControlGroupToDashboardSavedObject(savedDashboard, currentState); + if (hasTaggingCapabilities(savedDashboard)) { savedDashboard.setTags(tags); } diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts index 6d06863d02179..0fa7487390cd8 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts @@ -13,7 +13,13 @@ import { debounceTime, tap } from 'rxjs/operators'; import { DashboardContainer } from '../embeddable'; import { esFilters, Filter, Query } from '../../services/data'; import { DashboardConstants, DashboardSavedObject } from '../..'; -import { setExpandedPanelId, setFullScreenMode, setPanels, setQuery } from '../state'; +import { + setControlGroupState, + setExpandedPanelId, + setFullScreenMode, + setPanels, + setQuery, +} from '../state'; import { diffDashboardContainerInput } from './diff_dashboard_state'; import { replaceUrlHashQuery } from '../../../../kibana_utils/public'; import { DashboardBuildContext, DashboardContainerInput } from '../../types'; @@ -113,6 +119,10 @@ export const applyContainerChangesToState = ({ if (!_.isEqual(input.expandedPanelId, latestState.expandedPanelId)) { dispatchDashboardStateChange(setExpandedPanelId(input.expandedPanelId)); } + + if (!_.isEqual(input.controlGroupInput, latestState.controlGroupInput)) { + dispatchDashboardStateChange(setControlGroupState(input.controlGroupInput)); + } dispatchDashboardStateChange(setFullScreenMode(input.isFullScreenMode)); }; diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts index 3a1d60696331a..73949aa324da3 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { uniqBy } from 'lodash'; +import { merge, uniqBy } from 'lodash'; import deepEqual from 'fast-deep-equal'; import { Observable, pipe } from 'rxjs'; import { distinctUntilChanged, switchMap, filter, mapTo, map } from 'rxjs/operators'; @@ -30,6 +30,11 @@ export const syncDashboardIndexPatterns = ({ filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), map((container: DashboardContainer): IndexPattern[] | undefined => { let panelIndexPatterns: IndexPattern[] = []; + + if (container.controlGroup) { + panelIndexPatterns.push(...(container.controlGroup.getOutput().dataViews ?? [])); + } + Object.values(container.getChildIds()).forEach((id) => { const embeddableInstance = container.getChild(id); if (isErrorEmbeddable(embeddableInstance)) return; @@ -77,8 +82,7 @@ export const syncDashboardIndexPatterns = ({ }) ); - return dashboardContainer - .getOutput$() + return merge(dashboardContainer.getOutput$(), dashboardContainer.controlGroup?.getOutput$()) .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) .subscribe(); }; diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index 1acf806ae2f0d..5463b52cceb55 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -10,6 +10,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query } from '../../services/data'; import { ViewMode } from '../../services/embeddable'; +import { DashboardControlGroupInput } from '../lib/dashboard_control_group'; import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; export const dashboardStateSlice = createSlice({ @@ -41,6 +42,12 @@ export const dashboardStateSlice = createSlice({ state.tags = action.payload.tags; } }, + setControlGroupState: ( + state, + action: PayloadAction + ) => { + state.controlGroupInput = action.payload; + }, setUseMargins: (state, action: PayloadAction) => { state.options.useMargins = action.payload; }, @@ -92,6 +99,7 @@ export const dashboardStateSlice = createSlice({ export const { setStateFromSaveModal, + setControlGroupState, setDashboardOptions, setExpandedPanelId, setHidePanelTitles, diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 8772f14a6ec4c..38fe62e40f1f8 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -18,6 +18,8 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { DashboardOptions } from '../types'; +import { ControlStyle } from '../../../presentation_util/public'; + export interface DashboardSavedObject extends SavedObject { id?: string; timeRestore: boolean; @@ -36,6 +38,8 @@ export interface DashboardSavedObject extends SavedObject { getFullEditPath: (editMode?: boolean) => string; outcome?: string; aliasId?: string; + + controlGroupInput?: { controlStyle?: ControlStyle; panelsJSON?: string }; } const defaults = { @@ -86,6 +90,13 @@ export function createSavedDashboardClass( value: { type: 'integer' }, }, }, + controlGroupInput: { + type: 'object', + properties: { + controlStyle: { type: 'keyword' }, + panelsJSON: { type: 'text' }, + }, + }, }; public static fieldOrder = ['title', 'description']; public static searchSource = true; diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 651a51834a794..70221f4fb9474 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -37,6 +37,7 @@ import { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; +import { DashboardControlGroupInput } from './application/lib/dashboard_control_group'; export { SavedDashboardPanel }; @@ -65,6 +66,8 @@ export interface DashboardState { expandedPanelId?: string; options: DashboardOptions; panels: DashboardPanelMap; + + controlGroupInput?: DashboardControlGroupInput; } /** @@ -74,6 +77,7 @@ export type RawDashboardState = Omit & { panels: Saved export interface DashboardContainerInput extends ContainerInput { dashboardCapabilities?: DashboardAppCapabilities; + controlGroupInput?: DashboardControlGroupInput; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 068883c429e61..22ef0a1a1921a 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -51,6 +51,12 @@ export const createDashboardSavedObjectType = ({ value: { type: 'integer', index: false, doc_values: false }, }, }, + controlGroupInput: { + properties: { + controlStyle: { type: 'keyword', index: false, doc_values: false }, + panelsJSON: { type: 'text', index: false }, + }, + }, timeFrom: { type: 'keyword', index: false, doc_values: false }, timeRestore: { type: 'boolean', index: false, doc_values: false }, timeTo: { type: 'keyword', index: false, doc_values: false }, diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 06133fb2160c0..b8e4c2d0e016c 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -246,7 +246,6 @@ export abstract class Container< [panelState.explicitInput.id]: panelState, }, } as Partial); - return await this.untilEmbeddableLoaded(panelState.explicitInput.id); } diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index b958f3de0814f..c4c98888b886a 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,8 +10,9 @@ import { i18n } from '@kbn/i18n'; export const LABS_PROJECT_PREFIX = 'labs:'; export const DEFER_BELOW_FOLD = `${LABS_PROJECT_PREFIX}dashboard:deferBelowFold` as const; +export const DASHBOARD_CONTROLS = `${LABS_PROJECT_PREFIX}dashboard:dashboardControls` as const; -export const projectIDs = [DEFER_BELOW_FOLD] as const; +export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; @@ -34,6 +35,20 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { }), solutions: ['dashboard'], }, + [DASHBOARD_CONTROLS]: { + id: DASHBOARD_CONTROLS, + isActive: false, + isDisplayed: true, + environments: ['kibana', 'browser', 'session'], + name: i18n.translate('presentationUtil.labs.enableDeferBelowFoldProjectName', { + defaultMessage: 'Enable dashboard controls', + }), + description: i18n.translate('presentationUtil.labs.enableDeferBelowFoldProjectDescription', { + defaultMessage: + 'Enables the controls system for dashboard, which allows dashboard authors to more easily build interactive elements for their users.', + }), + solutions: ['dashboard'], + }, }; export type ProjectID = typeof projectIDs[number]; diff --git a/src/plugins/presentation_util/kibana.json b/src/plugins/presentation_util/kibana.json index d7fe9b558e606..210937b335e50 100644 --- a/src/plugins/presentation_util/kibana.json +++ b/src/plugins/presentation_util/kibana.json @@ -10,6 +10,6 @@ "server": true, "ui": true, "extraPublicDirs": ["common/lib"], - "requiredPlugins": ["savedObjects"], + "requiredPlugins": ["savedObjects", "data", "dataViews", "embeddable", "kibanaReact"], "optionalPlugins": [] } diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts deleted file mode 100644 index 3f89e2e549d2a..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 { ControlsService } from '../controls_service'; -import { InputControlFactory } from '../../../services/controls'; -import { flightFields, getFlightSearchOptions } from './flights'; -import { OptionsListEmbeddableFactory } from '../control_types/options_list'; - -export const getControlsServiceStub = () => { - const controlsServiceStub = new ControlsService(); - - const optionsListFactoryStub = new OptionsListEmbeddableFactory( - ({ field, search }) => - new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), - () => Promise.resolve([{ title: 'demo data flights', fields: [] }]), - () => Promise.resolve(flightFields) - ); - - // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory - const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; - optionsListControlFactory.getDefaultInput = () => ({}); - controlsServiceStub.registerInputControlType(optionsListControlFactory); - return controlsServiceStub; -}; diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts deleted file mode 100644 index 941b91c0c92f1..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 { map, uniq } from 'lodash'; -import { flights } from '../../fixtures/flights'; - -export type Flight = typeof flights[number]; -export type FlightField = keyof Flight; - -export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); - -export const getFlightSearchOptions = (field: string, search?: string): string[] => { - const options = getFlightOptions(field) - .map((option) => option + '') - .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); - if (options.length > 10) options.length = 10; - return options; -}; - -export const flightFieldLabels: Record = { - AvgTicketPrice: 'Average Ticket Price', - Cancelled: 'Cancelled', - Carrier: 'Carrier', - dayOfWeek: 'Day of Week', - Dest: 'Destination', - DestAirportID: 'Destination Airport ID', - DestCityName: 'Destination City', - DestCountry: 'Destination Country', - DestLocation: 'Destination Location', - DestRegion: 'Destination Region', - DestWeather: 'Destination Weather', - DistanceKilometers: 'Distance (km)', - DistanceMiles: 'Distance (mi)', - FlightDelay: 'Flight Delay', - FlightDelayMin: 'Flight Delay (min)', - FlightDelayType: 'Flight Delay Type', - FlightNum: 'Flight Number', - FlightTimeHour: 'Flight Time (hr)', - FlightTimeMin: 'Flight Time (min)', - Origin: 'Origin', - OriginAirportID: 'Origin Airport ID', - OriginCityName: 'Origin City', - OriginCountry: 'Origin Country', - OriginLocation: 'Origin Location', - OriginRegion: 'Origin Region', - OriginWeather: 'Origin Weather', - timestamp: 'Timestamp', -}; - -export const flightFields = Object.keys(flightFieldLabels).map((field) => ({ - name: field, - type: 'string', - aggregatable: true, -})); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx index f984b7c996a03..f46cf006d5e04 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -7,8 +7,8 @@ */ import React, { useEffect, useMemo } from 'react'; -import uuid from 'uuid'; +import uuid from 'uuid'; import { decorators } from './decorators'; import { pluginServices, registry } from '../../../services/storybook'; import { populateStorybookControlFactories } from './storybook_control_factories'; @@ -25,7 +25,7 @@ export default { decorators, }; -const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => { +const ControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); pluginServices.setRegistry(registry.start({})); @@ -35,11 +35,6 @@ const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels } (async () => { const factory = new ControlGroupContainerFactory(); const controlGroupContainerEmbeddable = await factory.create({ - inheritParentState: { - useQuery: false, - useFilters: false, - useTimerange: false, - }, controlStyle: 'oneLine', panels: panels ?? {}, id: uuid.v4(), @@ -53,9 +48,9 @@ const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels } return
; }; -export const EmptyControlGroupStory = () => ; +export const EmptyControlGroupStory = () => ; export const ConfiguredControlGroupStory = () => ( - ( explicitInput: { title: 'Origin City', id: 'optionsList1', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'OriginCityName', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'OriginCityName', selectedOptions: ['Toronto'], } as OptionsListEmbeddableInput, }, @@ -82,14 +71,8 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Destination City', id: 'optionsList2', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'DestCityName', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'DestCityName', selectedOptions: ['London'], } as OptionsListEmbeddableInput, }, @@ -100,14 +83,8 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Carrier', id: 'optionsList3', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'Carrier', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'Carrier', } as OptionsListEmbeddableInput, }, }} diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts index deb5b85336f27..e4429c1d69b13 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts @@ -6,28 +6,17 @@ * Side Public License, v 1. */ -import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; -import { InputControlFactory, PresentationControlsService } from '../../../services/controls'; +import { PresentationControlsService } from '../../../services/controls'; +import { ControlFactory } from '..'; export const populateStorybookControlFactories = ( controlsServiceStub: PresentationControlsService ) => { - const optionsListFactoryStub = new OptionsListEmbeddableFactory( - ({ field, search }) => - new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), - () => - Promise.resolve([ - { - title: 'demo data flights', - fields: [], - }, - ]), - () => Promise.resolve(flightFields) - ); + const optionsListFactoryStub = new OptionsListEmbeddableFactory(); // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory - const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; + const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory; optionsListControlFactory.getDefaultInput = () => ({}); - controlsServiceStub.registerInputControlType(optionsListControlFactory); + controlsServiceStub.registerControlType(optionsListControlFactory); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 3c3876afb7763..2972b286e1b2c 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -13,6 +13,7 @@ import { EuiFormControlLayout, EuiFormLabel, EuiFormRow, + EuiLoadingChart, EuiToolTip, } from '@elastic/eui'; @@ -87,13 +88,18 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con
); + const embeddableParentClassNames = classNames('controlFrame--control', { + 'controlFrame--twoLine': controlStyle === 'twoLine', + 'controlFrame--oneLine': controlStyle === 'oneLine', + }); + const form = ( - {customPrepend ?? null} + {(embeddable && customPrepend) ?? null} {usingTwoLineLayout ? undefined : ( {title} @@ -102,20 +108,26 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } > -
+ {embeddable && ( +
+ )} + {!embeddable && ( +
+
+ +
+
+ )} ); return ( <> - {enableActions && floatingActions} + {embeddable && enableActions && floatingActions} {form} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx index 4d5e8bc270e23..6dd140971e648 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -135,7 +135,7 @@ export const ControlGroup = () => { aria-label={ControlGroupStrings.management.getManageButtonTitle()} iconType="gear" color="text" - data-test-subj="inputControlsSortingButton" + data-test-subj="controlsManagementButton" onClick={() => openFlyout(forwardAllContext(, reduxContainerContext)) } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss index f49efa7aab043..3d7d11b6d0284 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -91,6 +91,13 @@ $controlMinWidth: $euiSize * 14; width: 100%; } } + + .controlFrame--controlLoading { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } } &-small { diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 35e490b0ea530..ac764c53eb59e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -10,179 +10,173 @@ import { i18n } from '@kbn/i18n'; export const ControlGroupStrings = { getEmbeddableTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.title', { + i18n.translate('presentationUtil.controls.controlGroup.title', { defaultMessage: 'Control group', }), manageControl: { getFlyoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.flyoutTitle', { defaultMessage: 'Manage control', }), getTitleInputTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Title', }), getWidthInputTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Control width', }), getSaveChangesTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', }), getCancelTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.cancelTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), }, management: { getAddControlTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.addControl', { + i18n.translate('presentationUtil.controls.controlGroup.management.addControl', { defaultMessage: 'Add control', }), getManageButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { + i18n.translate('presentationUtil.controls.controlGroup.management.buttonTitle', { defaultMessage: 'Manage controls', }), getFlyoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { + i18n.translate('presentationUtil.controls.controlGroup.management.flyoutTitle', { defaultMessage: 'Manage controls', }), getDefaultWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { + i18n.translate('presentationUtil.controls.controlGroup.management.defaultWidthTitle', { defaultMessage: 'Default width', }), getLayoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { + i18n.translate('presentationUtil.controls.controlGroup.management.layoutTitle', { defaultMessage: 'Layout', }), getDeleteButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete', { defaultMessage: 'Delete control', }), getSetAllWidthsToDefaultTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { + i18n.translate('presentationUtil.controls.controlGroup.management.setAllWidths', { defaultMessage: 'Set all widths to default', }), getDeleteAllButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteAll', { defaultMessage: 'Delete all', }), controlWidth: { getWidthSwitchLegend: () => i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', + 'presentationUtil.controls.controlGroup.management.layout.controlWidthLegend', { defaultMessage: 'Change control width', } ), getAutoWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.auto', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.auto', { defaultMessage: 'Auto', }), getSmallWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.small', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.small', { defaultMessage: 'Small', }), getMediumWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.medium', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.medium', { defaultMessage: 'Medium', }), getLargeWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.large', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.large', { defaultMessage: 'Large', }), }, controlStyle: { getDesignSwitchLegend: () => i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.layout.designSwitchLegend', + 'presentationUtil.controls.controlGroup.management.layout.designSwitchLegend', { defaultMessage: 'Switch control designs', } ), getSingleLineTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.singleLine', { defaultMessage: 'Single line layout', }), getTwoLineTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.twoLine', { defaultMessage: 'Two line layout', }), }, deleteControls: { getDeleteAllTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.delete.deleteAllTitle', - { - defaultMessage: 'Delete all controls?', - } - ), + i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteAllTitle', { + defaultMessage: 'Delete all controls?', + }), getDeleteTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.delete.deleteTitle', - { - defaultMessage: 'Delete control?', - } - ), + i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteTitle', { + defaultMessage: 'Delete control?', + }), getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.sub', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.sub', { defaultMessage: 'Controls are not recoverable once removed.', }), getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.confirm', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.confirm', { defaultMessage: 'Delete', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.cancel', { defaultMessage: 'Cancel', }), }, discardChanges: { getTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.title', { defaultMessage: 'Discard?', }), getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.sub', { defaultMessage: 'Discard changes to this control? Changes are not recoverable once discardsd.', }), getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.confirm', { defaultMessage: 'Discard', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.cancel', { defaultMessage: 'Cancel', }), }, discardNewControl: { getTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.title', { defaultMessage: 'Discard?', }), getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.sub', { defaultMessage: 'Discard new control? Controls are not recoverable once discarded.', }), getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.confirm', { defaultMessage: 'Discard', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.cancel', { defaultMessage: 'Cancel', }), }, }, floatingActions: { getEditButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { + i18n.translate('presentationUtil.controls.controlGroup.floatingActions.editTitle', { defaultMessage: 'Manage control', }), getRemoveButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { + i18n.translate('presentationUtil.controls.controlGroup.floatingActions.removeTitle', { defaultMessage: 'Remove control', }), }, diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx index 9f59fe98cc0c1..5694d9b4dd0c3 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx @@ -21,17 +21,16 @@ import { pluginServices } from '../../../../services'; import { forwardAllContext } from './forward_all_context'; import { OverlayRef } from '../../../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; -import { InputControlInput } from '../../../../services/controls'; import { DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; -import { ControlWidth, IEditableControlFactory } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; export const CreateControlButton = () => { // Presentation Services Context const { overlays, controls } = pluginServices.getHooks(); - const { getInputControlTypes, getControlFactory } = controls.useService(); + const { getControlTypes, getControlFactory } = controls.useService(); const { openFlyout, openConfirm } = overlays.useService(); // Redux embeddable container Context @@ -55,8 +54,8 @@ export const CreateControlButton = () => { const factory = getControlFactory(type); if (!factory) throw new EmbeddableFactoryNotFoundError(type); - const initialInputPromise = new Promise>((resolve, reject) => { - let inputToReturn: Partial = {}; + const initialInputPromise = new Promise>((resolve, reject) => { + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -109,18 +108,17 @@ export const CreateControlButton = () => { ); }; - if (getInputControlTypes().length === 0) return null; + if (getControlTypes().length === 0) return null; const commonButtonProps = { iconType: 'plus', color: 'text' as EuiButtonIconColor, - 'data-test-subj': 'inputControlsSortingButton', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; - if (getInputControlTypes().length > 1) { + if (getControlTypes().length > 1) { const items: ReactElement[] = []; - getInputControlTypes().forEach((type) => { + getControlTypes().forEach((type) => { const factory = getControlFactory(type); items.push( { ); } return ( - createNewControl(getInputControlTypes()[0])} - /> + createNewControl(getControlTypes()[0])} /> ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx index db15db71fd996..0aa88fbec98ec 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -12,15 +12,14 @@ import React, { useEffect, useRef } from 'react'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; -import { IEditableControlFactory } from '../../types'; import { pluginServices } from '../../../../services'; import { forwardAllContext } from './forward_all_context'; import { OverlayRef } from '../../../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; +import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; -import { InputControlInput } from '../../../../services/controls'; export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Presentation Services Context @@ -55,7 +54,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const factory = getControlFactory(panel.type); const embeddable = await untilEmbeddableLoaded(embeddableId); - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx index a722bed6c07d2..87ae86fcfc99c 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx @@ -7,23 +7,54 @@ */ import React from 'react'; +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 { - InputControlEmbeddable, - InputControlInput, - InputControlOutput, -} from '../../../../services/controls'; + distinctUntilChanged, + debounceTime, + catchError, + switchMap, + map, + take, +} from 'rxjs/operators'; + import { pluginServices } from '../../../../services'; -import { ControlGroupInput, ControlPanelState } from '../types'; +import { DataView } from '../../../../../../data_views/public'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; import { Container, EmbeddableFactory } from '../../../../../../embeddable/public'; +import { ControlGroupInput, ControlGroupOutput, ControlPanelState } from '../types'; import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; -export class ControlGroupContainer extends Container { +export class ControlGroupContainer extends Container< + ControlInput, + ControlGroupInput, + ControlGroupOutput +> { public readonly type = CONTROL_GROUP_TYPE; + private subscriptions: Subscription = new Subscription(); + + public untilReady = () => { + const panelsLoading = () => + Object.values(this.getOutput().embeddableLoaded).some((loaded) => !loaded); + if (panelsLoading()) { + return new Promise((resolve, reject) => { + const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { + if (this.destroyed) reject(); + if (!panelsLoading()) { + subscription.unsubscribe(); + resolve(); + } + }); + }); + } + return Promise.resolve(); + }; constructor(initialInput: ControlGroupInput, parent?: Container) { super( @@ -32,10 +63,44 @@ export class ControlGroupContainer extends Container 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( + this.getOutput$().pipe(anyChildChangePipe, take(1)), // the first time filters are built, don't debounce so that initial filters are built immediately + this.getOutput$().pipe(anyChildChangePipe, debounceTime(10)) + ).subscribe(this.recalculateOutput) + ); } - protected createNewPanelState( - factory: EmbeddableFactory, + private recalculateOutput = () => { + const allFilters: Filter[] = []; + const allDataViews: DataView[] = []; + Object.values(this.children).map((child) => { + const childOutput = child.getOutput() as ControlOutput; + allFilters.push(...(childOutput?.filters ?? [])); + allDataViews.push(...(childOutput.dataViews ?? [])); + }); + this.updateOutput({ filters: uniqFilters(allFilters), dataViews: uniqBy(allDataViews, 'id') }); + }; + + protected createNewPanelState( + factory: EmbeddableFactory, partial: Partial = {} ): ControlPanelState { const panelState = super.createNewPanelState(factory, partial); @@ -50,16 +115,21 @@ export class ControlGroupContainer extends Container; } - protected getInheritedInput(id: string): InputControlInput { - const { filters, query, timeRange, inheritParentState } = this.getInput(); + protected getInheritedInput(id: string): ControlInput { + const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); return { - filters: inheritParentState.useFilters ? filters : undefined, - query: inheritParentState.useQuery ? query : undefined, - timeRange: inheritParentState.useTimerange ? timeRange : undefined, + filters: ignoreParentSettings?.ignoreFilters ? undefined : filters, + query: ignoreParentSettings?.ignoreQuery ? undefined : query, + timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, id, }; } + public destroy() { + super.destroy(); + this.subscriptions.unsubscribe(); + } + public render(dom: HTMLElement) { const PresentationUtilProvider = pluginServices.getContextProvider(); ReactDOM.render( diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts index e50b1c5d734e4..61dbe9451f65f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts @@ -46,10 +46,10 @@ export class ControlGroupContainerFactory public getDefaultInput(): Partial { return { panels: {}, - inheritParentState: { - useFilters: true, - useQuery: true, - useTimerange: true, + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, }, }; } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/index.ts b/src/plugins/presentation_util/public/components/controls/control_group/index.ts new file mode 100644 index 0000000000000..49ee4e46fce3a --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CONTROL_GROUP_TYPE } from './control_group_constants'; +export { ControlGroupContainer } from './embeddable/control_group_container'; +export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; +export { ControlGroupContainerEmbeddable, ControlGroupInput, ControlGroupOutput } from './types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/presentation_util/public/components/controls/control_group/types.ts index 438eee1c461dd..fde2d995f3536 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/types.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/types.ts @@ -6,24 +6,29 @@ * Side Public License, v 1. */ -import { PanelState, EmbeddableInput } from '../../../../../embeddable/public'; -import { InputControlInput } from '../../../services/controls'; -import { ControlStyle, ControlWidth } from '../types'; +import { CommonControlOutput, ControlStyle, ControlWidth, ControlInput } from '../types'; +import { + Container, + PanelState, + EmbeddableInput, + ContainerOutput, +} from '../../../../../embeddable/public'; -export interface ControlGroupInput - extends EmbeddableInput, - Omit { - inheritParentState: { - useFilters: boolean; - useQuery: boolean; - useTimerange: boolean; - }; +export interface ControlGroupInput extends EmbeddableInput, ControlInput { defaultControlWidth?: ControlWidth; controlStyle: ControlStyle; panels: ControlsPanels; } -export interface ControlPanelState +export type ControlGroupOutput = ContainerOutput & CommonControlOutput; + +export type ControlGroupContainerEmbeddable = Container< + ControlInput, + ControlGroupInput, + ControlGroupOutput +>; + +export interface ControlPanelState extends PanelState { order: number; width: ControlWidth; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/index.ts b/src/plugins/presentation_util/public/components/controls/control_types/index.ts new file mode 100644 index 0000000000000..c6bc23568702f --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { OPTIONS_LIST_CONTROL } from './options_list/options_list_embeddable'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts index 63275f12076ff..d547b42078b3b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts @@ -7,4 +7,4 @@ */ export { OptionsListEmbeddableFactory } from './options_list_embeddable_factory'; -export { OptionsListEmbeddable } from './options_list_embeddable'; +export { OptionsListEmbeddable, OPTIONS_LIST_CONTROL } from './options_list_embeddable'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss index b74a08d96c8c3..e9a4ef215733e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss @@ -7,24 +7,10 @@ height: 100%; } -.optionsList--loadingOverlay { - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - position: absolute; - align-items: center; - justify-content: center; - background-color: $euiColorEmptyShade; -} - .optionsList--items { @include euiScrollBar; overflow-y: auto; - position: relative; - min-height: $euiSize * 5; max-height: $euiSize * 30; width: $euiSize * 25; max-width: 100%; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index 900570b38ca4d..554d50922bf8d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -9,8 +9,9 @@ import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { debounce } from 'lodash'; import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; import { OptionsListEmbeddableInput } from './options_list_embeddable'; import { OptionsListPopover } from './options_list_popover_component'; @@ -31,7 +32,7 @@ export const OptionsListComponent = ({ componentStateSubject, }: { typeaheadSubject: Subject; - componentStateSubject: Subject; + componentStateSubject: BehaviorSubject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [searchString, setSearchString] = useState(''); @@ -43,15 +44,21 @@ export const OptionsListComponent = ({ actions: { replaceSelection }, } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { twoLineLayout, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + const { controlStyle, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); // useStateObservable to get component state from Embeddable const { availableOptions, loading } = useStateObservable( componentStateSubject, - { - loading: true, - } + componentStateSubject.getValue() + ); + + // debounce loading state so loading doesn't flash when user types + const [buttonLoading, setButtonLoading] = useState(true); + const debounceSetButtonLoading = useMemo( + () => debounce((latestLoading: boolean) => setButtonLoading(latestLoading), 100), + [] ); + useEffect(() => debounceSetButtonLoading(loading), [loading, debounceSetButtonLoading]); // remove all other selections if this control is single select useEffect(() => { @@ -78,8 +85,9 @@ export const OptionsListComponent = ({ const button = ( setIsPopoverOpen((openState) => !openState)} @@ -95,7 +103,7 @@ export const OptionsListComponent = ({ return ( ['onChange']; - fetchIndexPatterns: OptionsListIndexPatternFetcher; initialInput?: Partial; - fetchFields: OptionsListFieldFetcher; } interface OptionsListEditorState { singleSelect?: boolean; - indexPatternSelectOptions: Array>; - availableIndexPatterns?: { [key: string]: IIndexPattern }; - indexPattern?: IIndexPattern; + dataViewIdSelectOptions: Array>; + dataViewId?: string; - fieldSelectOptions: Array>; - availableFields?: { [key: string]: IFieldType }; - field?: IFieldType; + fieldNameSelectOptions: Array>; + fieldName?: string; } export const OptionsListEditor = ({ onChange, - fetchFields, initialInput, setValidState, - fetchIndexPatterns, }: OptionsListEditorProps) => { + // Presentation Services Context + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, get } = dataViews.useService(); + const [state, setState] = useState({ - indexPattern: initialInput?.indexPattern, - field: initialInput?.field, + dataViewId: initialInput?.dataViewId, + fieldName: initialInput?.fieldName, singleSelect: initialInput?.singleSelect, - indexPatternSelectOptions: [], - fieldSelectOptions: [], + dataViewIdSelectOptions: [], + fieldNameSelectOptions: [], }); const applySelection = ({ - field, + fieldName, singleSelect, - indexPattern, + dataViewId, }: { - field?: IFieldType; + fieldName?: string; singleSelect?: boolean; - indexPattern?: IIndexPattern; + dataViewId?: string; }) => { const newState = { - ...(field ? { field } : {}), - ...(indexPattern ? { indexPattern } : {}), + ...(fieldName ? { fieldName } : {}), + ...(dataViewId ? { dataViewId } : {}), ...(singleSelect !== undefined ? { singleSelect } : {}), }; /** @@ -79,68 +74,56 @@ export const OptionsListEditor = ({ useMount(() => { (async () => { - const newIndexPatterns = await fetchIndexPatterns(); - const newAvailableIndexPatterns = newIndexPatterns.reduce( - (acc: { [key: string]: IIndexPattern }, curr) => ((acc[curr.title] = curr), acc), - {} - ); - const newIndexPatternSelectOptions = newIndexPatterns.map((indexPattern) => ({ - value: indexPattern.title, - inputDisplay: indexPattern.title, + const newDataViews = await getIdsWithTitle(); + const newDataViewSelectOptions = newDataViews.map((dataView) => ({ + value: dataView.id, + inputDisplay: dataView.title, })); + setState((currentState) => ({ ...currentState, - availableIndexPatterns: newAvailableIndexPatterns, - indexPatternSelectOptions: newIndexPatternSelectOptions, + dataViewIdSelectOptions: newDataViewSelectOptions, })); })(); }); useEffect(() => { (async () => { - let newFieldSelectOptions: Array> = []; - let newAvailableFields: { [key: string]: IFieldType } = {}; - if (state.indexPattern) { - const newFields = await fetchFields(state.indexPattern); - newAvailableFields = newFields.reduce( - (acc: { [key: string]: IFieldType }, curr) => ((acc[curr.name] = curr), acc), - {} - ); - newFieldSelectOptions = newFields.map((field) => ({ + let newFieldNameSelectOptions: Array> = []; + if (state.dataViewId) { + const newFields = (await get(state.dataViewId)).fields; + newFieldNameSelectOptions = newFields.map((field) => ({ value: field.name, inputDisplay: field.displayName ?? field.name, })); } setState((currentState) => ({ ...currentState, - fieldSelectOptions: newFieldSelectOptions, - availableFields: newAvailableFields, + fieldNameSelectOptions: newFieldNameSelectOptions, })); })(); - }, [state.indexPattern, fetchFields]); + }, [get, state.dataViewId]); useEffect( - () => setValidState(Boolean(state.field) && Boolean(state.indexPattern)), - [state.field, setValidState, state.indexPattern] + () => setValidState(Boolean(state.fieldName) && Boolean(state.dataViewId)), + [state.fieldName, setValidState, state.dataViewId] ); return ( <> - applySelection({ indexPattern: state.availableIndexPatterns?.[patternTitle] }) - } - valueOfSelected={state.indexPattern?.title} + options={state.dataViewIdSelectOptions} + onChange={(dataViewId) => applySelection({ dataViewId })} + valueOfSelected={state.dataViewId} /> applySelection({ field: state.availableFields?.[fieldName] })} - valueOfSelected={state.field?.name} + disabled={!state.dataViewId} + options={state.fieldNameSelectOptions} + onChange={(fieldName) => applySelection({ fieldName })} + valueOfSelected={state.fieldName} /> diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 93330772d7cad..4878dcc882802 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -8,17 +8,28 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { merge, Subject, Subscription } from 'rxjs'; +import { + buildEsQuery, + buildPhraseFilter, + buildPhrasesFilter, + compareFilters, + Filter, +} from '@kbn/es-query'; +import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { isEqual } from 'lodash'; import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; -import { InputControlInput, InputControlOutput } from '../../../../services/controls'; -import { esFilters, IIndexPattern, IFieldType } from '../../../../../../data/public'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; +import { PresentationDataViewsService } from '../../../../services/data_views'; +import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { PresentationDataService } from '../../../../services/data'; +import { DataView } from '../../../../../../data_views/public'; import { optionsListReducers } from './options_list_reducers'; +import { OptionsListStrings } from './options_list_strings'; +import { pluginServices } from '../../../../services'; +import { ControlInput, ControlOutput } from '../..'; const diffDataFetchProps = ( current?: OptionsListDataFetchProps, @@ -28,70 +39,70 @@ const diffDataFetchProps = ( const { filters: currentFilters, ...currentWithoutFilters } = current; const { filters: lastFilters, ...lastWithoutFilters } = last; if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false; - if (!esFilters.compareFilters(lastFilters ?? [], currentFilters ?? [])) return false; + if (!compareFilters(lastFilters ?? [], currentFilters ?? [])) return false; return true; }; interface OptionsListDataFetchProps { search?: string; - field: IFieldType; - indexPattern: IIndexPattern; - query?: InputControlInput['query']; - filters?: InputControlInput['filters']; - timeRange?: InputControlInput['timeRange']; + fieldName: string; + dataViewId: string; + query?: ControlInput['query']; + filters?: ControlInput['filters']; } -export type OptionsListIndexPatternFetcher = () => Promise; -export type OptionsListFieldFetcher = (indexPattern: IIndexPattern) => Promise; - -export type OptionsListDataFetcher = (props: OptionsListDataFetchProps) => Promise; +const fieldMissingError = (fieldName: string) => + new Error(`field ${fieldName} not found in index pattern`); export const OPTIONS_LIST_CONTROL = 'optionsListControl'; -export interface OptionsListEmbeddableInput extends InputControlInput { - field: IFieldType; - indexPattern: IIndexPattern; +export interface OptionsListEmbeddableInput extends ControlInput { + fieldName: string; + dataViewId: string; selectedOptions?: string[]; singleSelect?: boolean; loading?: boolean; } -export class OptionsListEmbeddable extends Embeddable< - OptionsListEmbeddableInput, - InputControlOutput -> { +export class OptionsListEmbeddable extends Embeddable { public readonly type = OPTIONS_LIST_CONTROL; + public deferEmbeddableLoad = true; + + private subscriptions: Subscription = new Subscription(); private node?: HTMLElement; - // internal state for this input control. + // Presentation Util services + private dataService: PresentationDataService; + private dataViewsService: PresentationDataViewsService; + + // Internal data fetching state for this input control. private typeaheadSubject: Subject = new Subject(); + private dataView?: DataView; private searchString = ''; + // State to be passed down to component private componentState: OptionsListComponentState; - private componentStateSubject$ = new Subject(); - private updateComponentState(changes: Partial) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } + private componentStateSubject$ = new BehaviorSubject({ + loading: true, + }); - private subscriptions: Subscription = new Subscription(); + constructor(input: OptionsListEmbeddableInput, output: ControlOutput, parent?: IContainer) { + super(input, output, parent); // get filters for initial output... + + // Destructure presentation util services + ({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices()); - constructor( - input: OptionsListEmbeddableInput, - output: InputControlOutput, - private fetchData: OptionsListDataFetcher, - parent?: IContainer - ) { - super({ ...input, loading: true }, output, parent); - this.fetchData = fetchData; + this.componentState = { loading: true }; + this.updateComponentState(this.componentState); + this.initialize(); + } + + private setupSubscriptions = () => { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ - field: newInput.field, - indexPattern: newInput.indexPattern, + fieldName: newInput.fieldName, + dataViewId: newInput.dataViewId, query: newInput.query, filters: newInput.filters, timeRange: newInput.timeRange, @@ -102,7 +113,20 @@ export class OptionsListEmbeddable extends Embeddable< // push searchString changes into a debounced typeahead subject this.typeaheadSubject = new Subject(); const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => (this.searchString = newSearchString), debounceTime(100)) + tap((newSearchString) => (this.searchString = newSearchString)), + debounceTime(100) + ); + + // clear all selections when field or index pattern change + this.subscriptions.add( + this.getInput$() + .pipe( + distinctUntilChanged( + (a, b) => isEqual(a.fieldName, b.fieldName) && isEqual(a.dataViewId, b.dataViewId) + ), + skip(1) // skip the first input update to preserve initial filters. + ) + .subscribe(() => this.updateInput({ selectedOptions: [] })) ); // fetch available options when input changes or when search string has changed @@ -110,45 +134,108 @@ export class OptionsListEmbeddable extends Embeddable< merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions) ); - // clear all selections when field or index pattern change + // build filters when selectedOptions change this.subscriptions.add( this.getInput$() .pipe( - distinctUntilChanged( - (a, b) => isEqual(a.field, b.field) && isEqual(a.indexPattern, b.indexPattern) - ), - skip(1) // skip the first change to preserve default selections after init + debounceTime(400), + distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)), + skip(1) // skip the first input update because initial filters will be built by initialize. ) - .subscribe(() => this.updateInput({ selectedOptions: [] })) + .subscribe(() => this.buildFilter()) ); + }; - this.componentState = { loading: true }; - this.updateComponentState(this.componentState); + private getCurrentDataView = async (): Promise => { + 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))); + } + this.updateOutput({ dataViews: [this.dataView] }); + return this.dataView; + }; + + private updateComponentState(changes: Partial) { + this.componentState = { + ...this.componentState, + ...changes, + }; + this.componentStateSubject$.next(this.componentState); } private fetchAvailableOptions = async () => { this.updateComponentState({ loading: true }); - const { indexPattern, timeRange, filters, field, query } = this.getInput(); - const newOptions = await this.fetchData({ - search: this.searchString, - indexPattern, - timeRange, - filters, + const { ignoreParentSettings, filters, fieldName, query } = this.getInput(); + const dataView = await this.getCurrentDataView(); + const field = dataView.getFieldByName(fieldName); + + if (!field) throw fieldMissingError(fieldName); + + 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, - query, }); this.updateComponentState({ availableOptions: newOptions, loading: false }); }; - public destroy = () => { - super.destroy(); - this.subscriptions.unsubscribe(); + private initialize = async () => { + const initialSelectedOptions = this.getInput().selectedOptions; + if (initialSelectedOptions) { + await this.getCurrentDataView(); + await this.buildFilter(); + } + this.setInitializationFinished(); + this.setupSubscriptions(); + }; + + private buildFilter = async () => { + const { fieldName, selectedOptions } = this.getInput(); + if (!selectedOptions || selectedOptions.length === 0) { + this.updateOutput({ filters: [] }); + return; + } + const dataView = await this.getCurrentDataView(); + const field = dataView.getFieldByName(this.getInput().fieldName); + + if (!field) throw fieldMissingError(fieldName); + + let newFilter: Filter; + if (selectedOptions.length === 1) { + newFilter = buildPhraseFilter(field, selectedOptions[0], dataView); + } else { + newFilter = buildPhrasesFilter(field, selectedOptions, dataView); + } + + newFilter.meta.key = field?.name; + this.updateOutput({ filters: [newFilter] }); }; reload = () => { this.fetchAvailableOptions(); }; + public destroy = () => { + super.destroy(); + this.subscriptions.unsubscribe(); + }; + public render = (node: HTMLElement) => { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx index 01c31a0bcbc51..109a01bf8c193 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx @@ -15,12 +15,9 @@ import { } from '../../types'; import { OptionsListEditor } from './options_list_editor'; import { - OptionsListDataFetcher, + OPTIONS_LIST_CONTROL, OptionsListEmbeddable, OptionsListEmbeddableInput, - OptionsListFieldFetcher, - OptionsListIndexPatternFetcher, - OPTIONS_LIST_CONTROL, } from './options_list_embeddable'; export class OptionsListEmbeddableFactory @@ -28,18 +25,10 @@ export class OptionsListEmbeddableFactory { public type = OPTIONS_LIST_CONTROL; - constructor( - private fetchData: OptionsListDataFetcher, - private fetchIndexPatterns: OptionsListIndexPatternFetcher, - private fetchFields: OptionsListFieldFetcher - ) { - this.fetchIndexPatterns = fetchIndexPatterns; - this.fetchFields = fetchFields; - this.fetchData = fetchData; - } + constructor() {} public create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) { - return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData, parent)); + return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, parent)); } public getControlEditor = ({ @@ -48,8 +37,6 @@ export class OptionsListEmbeddableFactory }: GetControlEditorComponentProps) => { return ({ setValidState }: ControlEditorProps) => ( ))} - {loading && ( -
-
-
- - -

{OptionsListStrings.popover.getLoadingMessage()}

-
-
-
- )} {!loading && (!availableOptions || availableOptions.length === 0) && (
diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 6b5fcc0835cad..579ac159304d2 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -11,52 +11,59 @@ import { i18n } from '@kbn/i18n'; export const OptionsListStrings = { summary: { getSeparator: () => - i18n.translate('presentationUtil.inputControls.optionsList.summary.separator', { + i18n.translate('presentationUtil.controls.optionsList.summary.separator', { defaultMessage: ', ', }), getPlaceholder: () => - i18n.translate('presentationUtil.inputControls.optionsList.summary.placeholder', { + i18n.translate('presentationUtil.controls.optionsList.summary.placeholder', { defaultMessage: 'Select...', }), }, editor: { getIndexPatternTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.indexPatternTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.indexPatternTitle', { defaultMessage: 'Index pattern', }), getFieldTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), getAllowMultiselectTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.allowMultiselectTitle', { defaultMessage: 'Allow multiselect', }), }, popover: { getLoadingMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.loading', { + i18n.translate('presentationUtil.controls.optionsList.popover.loading', { defaultMessage: 'Loading filters', }), getEmptyMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.empty', { + i18n.translate('presentationUtil.controls.optionsList.popover.empty', { defaultMessage: 'No filters found', }), getSelectionsEmptyMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.selectionsEmpty', { + i18n.translate('presentationUtil.controls.optionsList.popover.selectionsEmpty', { defaultMessage: 'You have no selections', }), getAllOptionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.allOptionsTitle', { + i18n.translate('presentationUtil.controls.optionsList.popover.allOptionsTitle', { defaultMessage: 'Show all options', }), getSelectedOptionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { + i18n.translate('presentationUtil.controls.optionsList.popover.selectedOptionsTitle', { defaultMessage: 'Show selected options only', }), getClearAllSelectionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.clearAllSelectionsTitle', { + i18n.translate('presentationUtil.controls.optionsList.popover.clearAllSelectionsTitle', { defaultMessage: 'Clear selections', }), }, + errors: { + getDataViewNotFoundError: (dataViewId: string) => + i18n.translate('presentationUtil.controls.optionsList.errors.dataViewNotFound', { + defaultMessage: 'Could not locate data view: {dataViewId}', + values: { dataViewId }, + }), + }, }; diff --git a/src/plugins/presentation_util/public/components/controls/controls_service.ts b/src/plugins/presentation_util/public/components/controls/controls_service.ts index 82242946e4563..436d36fcc9db0 100644 --- a/src/plugins/presentation_util/public/components/controls/controls_service.ts +++ b/src/plugins/presentation_util/public/components/controls/controls_service.ts @@ -6,31 +6,26 @@ * Side Public License, v 1. */ +import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '.'; import { EmbeddableFactory } from '../../../../embeddable/public'; -import { - InputControlEmbeddable, - ControlTypeRegistry, - InputControlFactory, - InputControlOutput, - InputControlInput, -} from '../../services/controls'; +import { ControlTypeRegistry } from '../../services/controls'; export class ControlsService { private controlsFactoriesMap: ControlTypeRegistry = {}; - public registerInputControlType = (factory: InputControlFactory) => { + public registerControlType = (factory: ControlFactory) => { this.controlsFactoriesMap[factory.type] = factory; }; public getControlFactory = < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => { return this.controlsFactoriesMap[type] as EmbeddableFactory; }; - public getInputControlTypes = () => Object.keys(this.controlsFactoriesMap); + public getControlTypes = () => Object.keys(this.controlsFactoriesMap); } diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts index c4f700ec059d9..379dff97cc871 100644 --- a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts @@ -6,16 +6,16 @@ * Side Public License, v 1. */ import { useEffect, useState } from 'react'; -import { InputControlEmbeddable } from '../../../services/controls'; +import { ControlEmbeddable } from '../types'; export const useChildEmbeddable = ({ untilEmbeddableLoaded, embeddableId, }: { - untilEmbeddableLoaded: (embeddableId: string) => Promise; + untilEmbeddableLoaded: (embeddableId: string) => Promise; embeddableId: string; }) => { - const [embeddable, setEmbeddable] = useState(); + const [embeddable, setEmbeddable] = useState(); useEffect(() => { let mounted = true; diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts index c317f11979f54..79decd14ba358 100644 --- a/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts @@ -13,11 +13,11 @@ export const useStateObservable = ( stateObservable: Observable, initialState: T ) => { + const [innerState, setInnerState] = useState(initialState); useEffect(() => { const subscription = stateObservable.subscribe((newState) => setInnerState(newState)); return () => subscription.unsubscribe(); }, [stateObservable]); - const [innerState, setInnerState] = useState(initialState); return innerState; }; diff --git a/src/plugins/presentation_util/public/components/controls/index.ts b/src/plugins/presentation_util/public/components/controls/index.ts new file mode 100644 index 0000000000000..c110bc348498d --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './control_group'; +export * from './control_types'; +export * from './types'; diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/presentation_util/public/components/controls/types.ts index 0704a601640e6..a96cef3ca110e 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -6,22 +6,60 @@ * Side Public License, v 1. */ -import { InputControlInput } from '../../services/controls'; +import { Filter, Query } from '@kbn/es-query'; +import { + EmbeddableFactory, + EmbeddableInput, + EmbeddableOutput, + IEmbeddable, +} from '../../../../embeddable/public'; +import { TimeRange } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; export type ControlStyle = 'twoLine' | 'oneLine'; +/** + * Generic control embeddable input and output + */ +export interface ParentIgnoreSettings { + ignoreFilters?: boolean; + ignoreQuery?: boolean; + ignoreTimerange?: boolean; +} + +export type ControlInput = EmbeddableInput & { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + controlStyle?: ControlStyle; + ignoreParentSettings?: ParentIgnoreSettings; +}; +export interface CommonControlOutput { + filters?: Filter[]; + dataViews?: DataView[]; +} + +export type ControlOutput = EmbeddableOutput & CommonControlOutput; + +export type ControlFactory = EmbeddableFactory; + +export type ControlEmbeddable< + TControlEmbeddableInput extends ControlInput = ControlInput, + TControlEmbeddableOutput extends ControlOutput = ControlOutput +> = IEmbeddable; + /** * Control embeddable editor types */ -export interface IEditableControlFactory { +export interface IEditableControlFactory { getControlEditor?: GetControlEditorComponent; } -export type GetControlEditorComponent = ( +export type GetControlEditorComponent = ( props: GetControlEditorComponentProps ) => ControlEditorComponent; -export interface GetControlEditorComponentProps { +export interface GetControlEditorComponentProps { onChange: (partial: Partial) => void; initialInput?: Partial; } diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts index 36ba1fcaa49b9..22883b8f0c813 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts @@ -18,7 +18,7 @@ type ManagedEmbeddableReduxStore = EnhancedStore & { asyncReducers: { [key: string]: Reducer }; injectReducer: (props: InjectReducerProps) => void; }; -const embeddablesStore = configureStore({ reducer: {} as { [key: string]: Reducer } }); +const embeddablesStore = configureStore({ reducer: (state) => state }); // store with blank reducers const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore; managedEmbeddablesStore.asyncReducers = {}; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index 4a112f7d6e574..ac2d6fef61884 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -10,6 +10,8 @@ import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react- import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Draft } from 'immer/dist/types/types-external'; +import { debounceTime } from 'rxjs/operators'; +import { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; import { @@ -26,6 +28,21 @@ import { import { getManagedEmbeddablesStore } from './generic_embeddable_store'; import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; +type InputWithFilters = Partial & { filters: Filter[] }; +export const stateContainsFilters = ( + state: Partial +): state is InputWithFilters => { + if ((state as InputWithFilters).filters) return true; + return false; +}; + +export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { + return filters.map((filter) => { + if (filter.meta.value) delete filter.meta.value; + return filter; + }); +}; + const getDefaultProps = (): Required< Pick, 'diffInput'> > => ({ @@ -43,6 +60,17 @@ const embeddableIsContainer = ( embeddable: IEmbeddable ): embeddable is IContainer => embeddable.isContainer; +export const getExplicitInput = ( + embeddable: IEmbeddable +): InputType => { + const root = embeddable.getRoot(); + if (!embeddableIsContainer(embeddable) && embeddableIsContainer(root)) { + return (root.getInput().panels[embeddable.id]?.explicitInput ?? + embeddable.getInput()) as InputType; + } + return embeddable.getInput() as InputType; +}; + /** * Place this wrapper around the react component when rendering an embeddable to automatically set up * redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext @@ -81,8 +109,12 @@ export const ReduxEmbeddableWrapper = (embeddable); + if (stateContainsFilters(initialState)) { + initialState.filters = cleanFiltersForSerialize(initialState.filters); + } const slice = createSlice>({ - initialState: embeddable.getInput(), + initialState, name: key, reducers: { ...reducers, updateEmbeddableReduxState }, }); @@ -143,10 +175,13 @@ const ReduxEmbeddableSync = { - const differences = diffInput(embeddable.getInput(), stateRef.current); + const differences = diffInput(getExplicitInput(embeddable), stateRef.current); if (differences && Object.keys(differences).length > 0) { + if (stateContainsFilters(differences)) { + differences.filters = cleanFiltersForSerialize(differences.filters); + } dispatch(updateEmbeddableReduxState(differences)); } }); @@ -156,7 +191,7 @@ const ReduxEmbeddableSync = { // When redux state changes, push differences to Embeddable Input. stateRef.current = currentState; - const differences = diffInput(currentState, embeddable.getInput()); + const differences = diffInput(currentState, getExplicitInput(embeddable)); if (differences && Object.keys(differences).length > 0) { embeddable.updateInput(differences); } diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 6628124717a1c..478e8a7cda032 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -54,6 +54,8 @@ export { SolutionToolbarPopover, } from './components/solution_toolbar'; +export * from './components/controls'; + export function plugin() { return new PresentationUtilPlugin(); } diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index f697f1a29eb82..627b3f1057d23 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -10,11 +10,17 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { pluginServices } from './services'; import { registry } from './services/kibana'; import { - PresentationUtilPluginSetup, - PresentationUtilPluginStart, PresentationUtilPluginSetupDeps, PresentationUtilPluginStartDeps, + ControlGroupContainerFactory, + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + GetControlEditorComponent, + OPTIONS_LIST_CONTROL, + CONTROL_GROUP_TYPE, + IEditableControlFactory, } from './types'; +import { OptionsListEmbeddableFactory } from './components/controls/control_types/options_list'; export class PresentationUtilPlugin implements @@ -25,10 +31,24 @@ export class PresentationUtilPlugin PresentationUtilPluginStartDeps > { + private inlineEditors: { [key: string]: GetControlEditorComponent | undefined } = {}; + public setup( _coreSetup: CoreSetup, _setupPlugins: PresentationUtilPluginSetupDeps ): PresentationUtilPluginSetup { + const { embeddable } = _setupPlugins; + + // register control group embeddable factory + embeddable.registerEmbeddableFactory(CONTROL_GROUP_TYPE, new ControlGroupContainerFactory()); + + // create control type embeddable factories. + const optionsListFactory = new OptionsListEmbeddableFactory(); + this.inlineEditors[OPTIONS_LIST_CONTROL] = ( + optionsListFactory as IEditableControlFactory + ).getControlEditor; + embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory); + return {}; } @@ -37,9 +57,19 @@ export class PresentationUtilPlugin startPlugins: PresentationUtilPluginStartDeps ): PresentationUtilPluginStart { 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 + (optionsListFactory as IEditableControlFactory).getControlEditor = + this.inlineEditors[OPTIONS_LIST_CONTROL]; + if (optionsListFactory) controlsService.registerControlType(optionsListFactory); + return { ContextProvider: pluginServices.getContextProvider(), - controlsService: pluginServices.getServices().controls, + controlsService, labsService: pluginServices.getServices().labs, }; } diff --git a/src/plugins/presentation_util/public/services/controls.ts b/src/plugins/presentation_util/public/services/controls.ts index 197e986381b10..76af24960bfe3 100644 --- a/src/plugins/presentation_util/public/services/controls.ts +++ b/src/plugins/presentation_util/public/services/controls.ts @@ -6,80 +6,54 @@ * Side Public License, v 1. */ -import { Filter } from '@kbn/es-query'; -import { Query, TimeRange } from '../../../data/public'; +import { EmbeddableFactory } from '../../../embeddable/public'; import { - EmbeddableFactory, - EmbeddableInput, - EmbeddableOutput, - IEmbeddable, -} from '../../../embeddable/public'; - -/** - * Control embeddable types - */ -export type InputControlFactory = EmbeddableFactory< - InputControlInput, - InputControlOutput, - InputControlEmbeddable ->; - -export type InputControlInput = EmbeddableInput & { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - twoLineLayout?: boolean; -}; - -export type InputControlOutput = EmbeddableOutput & { - filters?: Filter[]; -}; - -export type InputControlEmbeddable< - TInputControlEmbeddableInput extends InputControlInput = InputControlInput, - TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput -> = IEmbeddable; + ControlEmbeddable, + ControlFactory, + ControlOutput, + ControlInput, +} from '../components/controls/types'; export interface ControlTypeRegistry { - [key: string]: InputControlFactory; + [key: string]: ControlFactory; } export interface PresentationControlsService { - registerInputControlType: (factory: InputControlFactory) => void; + registerControlType: (factory: ControlFactory) => void; getControlFactory: < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => EmbeddableFactory; - getInputControlTypes: () => string[]; + getControlTypes: () => string[]; } export const getCommonControlsService = () => { const controlsFactoriesMap: ControlTypeRegistry = {}; - const registerInputControlType = (factory: InputControlFactory) => { + const registerControlType = (factory: ControlFactory) => { controlsFactoriesMap[factory.type] = factory; }; const getControlFactory = < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => { return controlsFactoriesMap[type] as EmbeddableFactory; }; - const getInputControlTypes = () => Object.keys(controlsFactoriesMap); + const getControlTypes = () => Object.keys(controlsFactoriesMap); return { - registerInputControlType, + registerControlType, getControlFactory, - getInputControlTypes, + getControlTypes, }; }; diff --git a/src/plugins/presentation_util/public/services/data.ts b/src/plugins/presentation_util/public/services/data.ts new file mode 100644 index 0000000000000..44f29dcd2d3ad --- /dev/null +++ b/src/plugins/presentation_util/public/services/data.ts @@ -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 { DataPublicPluginStart } from '../../../data/public'; + +export interface PresentationDataService { + autocomplete: DataPublicPluginStart['autocomplete']; +} diff --git a/src/plugins/presentation_util/public/services/data_views.ts b/src/plugins/presentation_util/public/services/data_views.ts new file mode 100644 index 0000000000000..912553893be3f --- /dev/null +++ b/src/plugins/presentation_util/public/services/data_views.ts @@ -0,0 +1,14 @@ +/* + * 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 { DataViewsPublicPluginStart } from '../../../data_views/public'; + +export interface PresentationDataViewsService { + get: DataViewsPublicPluginStart['get']; + getIdsWithTitle: DataViewsPublicPluginStart['getIdsWithTitle']; +} diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index 21012971ca86d..c7d8d2617888a 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -14,12 +14,16 @@ import { PresentationLabsService } from './labs'; import { registry as stubRegistry } from './stub'; import { PresentationOverlaysService } from './overlays'; import { PresentationControlsService } from './controls'; +import { PresentationDataViewsService } from './data_views'; +import { PresentationDataService } from './data'; export { PresentationCapabilitiesService } from './capabilities'; export { PresentationDashboardsService } from './dashboards'; export { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; + dataViews: PresentationDataViewsService; + data: PresentationDataService; capabilities: PresentationCapabilitiesService; overlays: PresentationOverlaysService; controls: PresentationControlsService; diff --git a/src/plugins/presentation_util/public/services/kibana/data.ts b/src/plugins/presentation_util/public/services/kibana/data.ts new file mode 100644 index 0000000000000..408e59fd4906c --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/data.ts @@ -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 { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationDataService } from '../data'; + +export type DataServiceFactory = KibanaPluginServiceFactory< + PresentationDataService, + PresentationUtilPluginStartDeps +>; + +export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { + const { + data: { autocomplete }, + } = startPlugins; + return { + autocomplete, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/data_views.ts b/src/plugins/presentation_util/public/services/kibana/data_views.ts new file mode 100644 index 0000000000000..254dae7791520 --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/data_views.ts @@ -0,0 +1,27 @@ +/* + * 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 { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationDataViewsService } from '../data_views'; +import { KibanaPluginServiceFactory } from '../create'; + +export type DataViewsServiceFactory = KibanaPluginServiceFactory< + PresentationDataViewsService, + PresentationUtilPluginStartDeps +>; + +export const dataViewsServiceFactory: DataViewsServiceFactory = ({ startPlugins }) => { + const { + dataViews: { get, getIdsWithTitle }, + } = startPlugins; + + return { + get, + getIdsWithTitle, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts index 48c921bff1efd..3820442555c26 100644 --- a/src/plugins/presentation_util/public/services/kibana/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -6,10 +6,6 @@ * Side Public License, v 1. */ -import { capabilitiesServiceFactory } from './capabilities'; -import { dashboardsServiceFactory } from './dashboards'; -import { overlaysServiceFactory } from './overlays'; -import { labsServiceFactory } from './labs'; import { PluginServiceProviders, KibanaPluginServiceParams, @@ -18,12 +14,14 @@ import { } from '../create'; import { PresentationUtilPluginStartDeps } from '../../types'; import { PresentationUtilServices } from '..'; -import { controlsServiceFactory } from './controls'; -export { capabilitiesServiceFactory } from './capabilities'; -export { dashboardsServiceFactory } from './dashboards'; -export { overlaysServiceFactory } from './overlays'; -export { labsServiceFactory } from './labs'; +import { capabilitiesServiceFactory } from './capabilities'; +import { dataViewsServiceFactory } from './data_views'; +import { dashboardsServiceFactory } from './dashboards'; +import { controlsServiceFactory } from './controls'; +import { overlaysServiceFactory } from './overlays'; +import { dataServiceFactory } from './data'; +import { labsServiceFactory } from './labs'; export const providers: PluginServiceProviders< PresentationUtilServices, @@ -31,6 +29,8 @@ export const providers: PluginServiceProviders< > = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), diff --git a/src/plugins/presentation_util/public/services/storybook/data.ts b/src/plugins/presentation_util/public/services/storybook/data.ts new file mode 100644 index 0000000000000..5f0bc3e2273d0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data.ts @@ -0,0 +1,19 @@ +/* + * 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 { DataPublicPluginStart } from '../../../../data/public'; +import { PluginServiceFactory } from '../create'; +import { PresentationDataService } from '../data'; +import { getFlightOptionsAsync } from './fixtures/flights'; + +export type DataServiceFactory = PluginServiceFactory; +export const dataServiceFactory: DataServiceFactory = () => ({ + autocomplete: { + getValueSuggestions: getFlightOptionsAsync, + } as unknown as DataPublicPluginStart['autocomplete'], +}); diff --git a/src/plugins/presentation_util/public/services/storybook/data_views.ts b/src/plugins/presentation_util/public/services/storybook/data_views.ts new file mode 100644 index 0000000000000..bafcae442c2e0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data_views.ts @@ -0,0 +1,27 @@ +/* + * 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 '../create'; +import { PresentationDataViewsService } from '../data_views'; +import { storybookFlightsDataView } from './fixtures/flights'; +import { DataViewsPublicPluginStart } from '../../../../data_views/public'; + +export type DataViewsServiceFactory = PluginServiceFactory; +export const dataViewsServiceFactory: DataViewsServiceFactory = () => ({ + get: (() => + new Promise((r) => + setTimeout(() => r(storybookFlightsDataView), 100) + ) as unknown) as DataViewsPublicPluginStart['get'], + getIdsWithTitle: (() => + new Promise((r) => + setTimeout( + () => r([{ id: storybookFlightsDataView.id, title: storybookFlightsDataView.title }]), + 100 + ) + ) as unknown) as DataViewsPublicPluginStart['getIdsWithTitle'], +}); diff --git a/src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts b/src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts new file mode 100644 index 0000000000000..55ec4d13bd515 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts @@ -0,0 +1,85 @@ +/* + * 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 { map, uniq } from 'lodash'; +import { flights } from '../fixtures/flights_data'; +import { + DataView, + DataViewField, + IndexPatternField, + IIndexPatternFieldList, +} from '../../../../../data_views/common'; + +export type Flight = typeof flights[number]; +export type FlightField = keyof Flight; + +export const flightFieldNames: FlightField[] = [ + 'AvgTicketPrice', + 'Cancelled', + 'Carrier', + 'dayOfWeek', + 'Dest', + 'DestAirportID', + 'DestCityName', + 'DestCountry', + 'DestLocation', + 'DestRegion', + 'DestWeather', + 'DistanceKilometers', + 'DistanceMiles', + 'FlightDelay', + 'FlightDelayMin', + 'FlightDelayType', + 'FlightNum', + 'FlightTimeHour', + 'FlightTimeMin', + 'Origin', + 'OriginAirportID', + 'OriginCityName', + 'OriginCountry', + 'OriginLocation', + 'OriginRegion', + 'OriginWeather', + 'timestamp', +]; + +export const flightFieldByName: { [key: string]: DataViewField } = {}; +flightFieldNames.forEach( + (flightFieldName) => + (flightFieldByName[flightFieldName] = { + name: flightFieldName, + type: 'string', + } as unknown as DataViewField) +); + +export const flightFields: DataViewField[] = Object.values(flightFieldByName); + +export const storybookFlightsDataView: DataView = { + id: 'demoDataFlights', + title: 'demo data flights', + fields: flightFields as unknown as IIndexPatternFieldList, + getFieldByName: (name: string) => flightFieldByName[name], +} as unknown as DataView; + +export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); + +export const getFlightSearchOptions = (field: string, search?: string): string[] => { + const options = getFlightOptions(field) + .map((option) => option + '') + .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); + if (options.length > 10) options.length = 10; + return options; +}; + +export const getFlightOptionsAsync = ({ + field, + query, +}: { + field: IndexPatternField; + query: string; +}) => new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, query)), 120)); diff --git a/src/plugins/presentation_util/public/components/fixtures/flights.ts b/src/plugins/presentation_util/public/services/storybook/fixtures/flights_data.ts similarity index 100% rename from src/plugins/presentation_util/public/components/fixtures/flights.ts rename to src/plugins/presentation_util/public/services/storybook/fixtures/flights_data.ts diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 9de4934d51300..1639316a1fe19 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -18,6 +18,8 @@ import { capabilitiesServiceFactory } from './capabilities'; import { PresentationUtilServices } from '..'; import { overlaysServiceFactory } from './overlays'; import { controlsServiceFactory } from './controls'; +import { dataViewsServiceFactory } from './data_views'; +import { dataServiceFactory } from './data'; export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; export { PresentationUtilServices } from '..'; @@ -32,6 +34,8 @@ export interface StorybookParams { export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts index 35aabdb465b14..2e312ff682927 100644 --- a/src/plugins/presentation_util/public/services/stub/index.ts +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -16,12 +16,17 @@ import { controlsServiceFactory } from './controls'; export { dashboardsServiceFactory } from './dashboards'; export { capabilitiesServiceFactory } from './capabilities'; +import { dataServiceFactory } from '../storybook/data'; +import { dataViewsServiceFactory } from '../storybook/data_views'; + export const providers: PluginServiceProviders = { dashboards: new PluginServiceProvider(dashboardsServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), }; export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 3903d1bc2786e..63690901b9be6 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -6,8 +6,11 @@ * Side Public License, v 1. */ -import { PresentationControlsService } from './services/controls'; +import { DataPublicPluginStart } from '../../data/public'; import { PresentationLabsService } from './services/labs'; +import { PresentationControlsService } from './services/controls'; +import { DataViewsPublicPluginStart } from '../../data_views/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PresentationUtilPluginSetup {} @@ -18,7 +21,13 @@ export interface PresentationUtilPluginStart { controlsService: PresentationControlsService; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginSetupDeps {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginStartDeps {} +export interface PresentationUtilPluginSetupDeps { + embeddable: EmbeddableSetup; +} +export interface PresentationUtilPluginStartDeps { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + dataViews: DataViewsPublicPluginStart; +} + +export * from './components/controls'; diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index caff10a90e84c..caabd0b18af71 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -22,6 +22,7 @@ { "path": "../saved_objects/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json"}, { "path": "../data/tsconfig.json" } ] } From a1b4a1221caf46a833be169b8901dd7404fa3090 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 20 Oct 2021 22:29:20 -0400 Subject: [PATCH 13/13] Inject and Extract, tracking of index pattern changes in control group --- .../dashboard_container_persistable_state.ts | 45 ++++++++- .../common/saved_dashboard_references.ts | 74 +++++++++++---- src/plugins/dashboard/common/types.ts | 15 +++ .../viewport/_dashboard_viewport.scss | 8 ++ .../viewport/dashboard_viewport.tsx | 2 +- .../lib/dashboard_control_group.ts | 93 ++++++++++++------- .../lib/sync_dashboard_index_patterns.ts | 22 +++-- .../control_group_persistable_state.ts | 85 +++++++++++++++++ .../common/controls/control_group/types.ts | 28 ++++++ .../options_list_persistable_state.ts | 47 ++++++++++ .../control_types/options_list/types.ts | 20 ++++ .../common/controls/index.ts | 10 ++ .../common/controls/types.ts | 28 ++++++ src/plugins/presentation_util/common/labs.ts | 4 +- .../presentation_util/common/lib/index.ts | 1 + .../component/control_frame_component.tsx | 7 +- .../component/control_group_component.tsx | 1 + .../controls/control_group/control_group.scss | 1 - .../control_group/editor/control_editor.tsx | 2 +- .../control_group/editor/create_control.tsx | 2 +- .../editor/edit_control_group.tsx | 2 +- .../editor_constants.ts} | 6 +- .../embeddable/control_group_container.tsx | 13 ++- .../control_group_container_factory.ts | 13 ++- .../controls/control_group/index.ts | 1 - .../controls/control_group/types.ts | 26 +----- .../options_list/options_list_embeddable.tsx | 18 +--- .../options_list_embeddable_factory.tsx | 16 +++- .../options_list_popover_component.tsx | 4 +- .../control_types/options_list/types.ts | 9 ++ .../public/components/controls/types.ts | 35 ++----- .../redux_embeddable_wrapper.tsx | 25 +++-- .../presentation_util/public/plugin.ts | 17 ++-- .../control_group_container_factory.ts | 25 +++++ .../options_list_embeddable_factory.ts | 22 +++++ .../presentation_util/server/plugin.ts | 17 +++- 36 files changed, 574 insertions(+), 170 deletions(-) create mode 100644 src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts create mode 100644 src/plugins/presentation_util/common/controls/control_group/types.ts create mode 100644 src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts create mode 100644 src/plugins/presentation_util/common/controls/control_types/options_list/types.ts create mode 100644 src/plugins/presentation_util/common/controls/index.ts create mode 100644 src/plugins/presentation_util/common/controls/types.ts rename src/plugins/presentation_util/public/components/controls/control_group/{control_group_constants.ts => editor/editor_constants.ts} (87%) create mode 100644 src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts create mode 100644 src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts create mode 100644 src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts diff --git a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts index 6104fcfdbe949..c04f2623d6d55 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts @@ -12,10 +12,17 @@ import { EmbeddableStateWithType, } from '../../../embeddable/common'; import { SavedObjectReference } from '../../../../core/types'; -import { DashboardContainerStateWithType, DashboardPanelState } from '../types'; +import { + DashboardContainerControlGroupInput, + DashboardContainerStateWithType, + DashboardPanelState, +} from '../types'; +import { CONTROL_GROUP_TYPE } from '../../../presentation_util/common/lib'; const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`; +const controlGroupReferencePrefix = 'controlGroup_'; + export const createInject = ( persistableStateService: EmbeddablePersistableStateService ): EmbeddablePersistableStateService['inject'] => { @@ -69,6 +76,26 @@ export const createInject = ( } } + // since the controlGroup is not part of the panels array, its references need to be injected separately + if ('controlGroupInput' in workingState && workingState.controlGroupInput) { + const controlGroupReferences = references + .filter((reference) => reference.name.indexOf(controlGroupReferencePrefix) === 0) + .map((reference) => ({ + ...reference, + name: reference.name.replace(controlGroupReferencePrefix, ''), + })); + + const { type, ...injectedControlGroupState } = persistableStateService.inject( + { + ...workingState.controlGroupInput, + type: CONTROL_GROUP_TYPE, + }, + controlGroupReferences + ); + workingState.controlGroupInput = + injectedControlGroupState as DashboardContainerControlGroupInput; + } + return workingState as EmbeddableStateWithType; }; }; @@ -120,6 +147,22 @@ export const createExtract = ( } } + // since the controlGroup is not part of the panels array, its references need to be extracted separately + if ('controlGroupInput' in workingState && workingState.controlGroupInput) { + const { state: extractedControlGroupState, references: controlGroupReferences } = + persistableStateService.extract({ + ...workingState.controlGroupInput, + type: CONTROL_GROUP_TYPE, + }); + workingState.controlGroupInput = + extractedControlGroupState as DashboardContainerControlGroupInput; + const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({ + ...reference, + name: `${controlGroupReferencePrefix}${reference.name}`, + })); + references.push(...prefixedControlGroupReferences); + } + return { state: workingState as EmbeddableStateWithType, references }; }; }; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index 4b3a379068c48..bc7358b49ceb4 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -7,13 +7,20 @@ */ import semverGt from 'semver/functions/gt'; import { SavedObjectAttributes, SavedObjectReference } from '../../../core/types'; -import { DashboardContainerStateWithType, DashboardPanelState } from './types'; +import { + DashboardContainerControlGroupInput, + DashboardContainerStateWithType, + DashboardPanelState, + RawControlGroupAttributes, +} from './types'; import { EmbeddablePersistableStateService } from '../../embeddable/common/types'; import { convertPanelStateToSavedDashboardPanel, convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; +import { CONTROL_GROUP_TYPE } from '../../presentation_util/common/lib'; + export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } @@ -35,10 +42,27 @@ function dashboardAttributesToState(attributes: SavedObjectAttributes): { inputPanels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; } + let controlGroupInput: DashboardContainerControlGroupInput | undefined; + if (attributes.controlGroupInput) { + const rawControlGroupInput = + attributes.controlGroupInput as unknown as RawControlGroupAttributes; + if (rawControlGroupInput.panelsJSON && typeof rawControlGroupInput.panelsJSON === 'string') { + const controlGroupPanels = JSON.parse(rawControlGroupInput.panelsJSON); + if (controlGroupPanels && typeof controlGroupPanels === 'object') { + controlGroupInput = { + ...rawControlGroupInput, + type: CONTROL_GROUP_TYPE, + panels: controlGroupPanels, + }; + } + } + } + return { panels: inputPanels, state: { id: attributes.id as string, + controlGroupInput, type: 'dashboard', panels: inputPanels.reduce>((current, panel, index) => { const panelIndex = panel.panelIndex || `${index}`; @@ -92,20 +116,27 @@ export function extractReferences( throw new Error(`"type" attribute is missing from panel "${missingTypeIndex}"`); } - const { state: extractedState, references: extractedReferences } = + const { references: extractedReferences, state: rawExtractedState } = deps.embeddablePersistableStateService.extract(state); + const extractedState = rawExtractedState as DashboardContainerStateWithType; + + const extractedPanels = panelStatesToPanels(extractedState.panels, panels); - const extractedPanels = panelStatesToPanels( - (extractedState as DashboardContainerStateWithType).panels, - panels - ); + const newAttributes = { + ...attributes, + panelsJSON: JSON.stringify(extractedPanels), + } as SavedObjectAttributes; + + if (extractedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...(attributes.controlGroupInput as SavedObjectAttributes), + panelsJSON: JSON.stringify(extractedState.controlGroupInput.panels), + }; + } return { references: [...references, ...extractedReferences], - attributes: { - ...attributes, - panelsJSON: JSON.stringify(extractedPanels), - }, + attributes: newAttributes, }; } @@ -131,16 +162,25 @@ export function injectReferences( const { panels, state } = dashboardAttributesToState(attributes); - const injectedState = deps.embeddablePersistableStateService.inject(state, references); - const injectedPanels = panelStatesToPanels( - (injectedState as DashboardContainerStateWithType).panels, - panels - ); + const injectedState = deps.embeddablePersistableStateService.inject( + state, + references + ) as DashboardContainerStateWithType; + const injectedPanels = panelStatesToPanels(injectedState.panels, panels); - return { + const newAttributes = { ...attributes, panelsJSON: JSON.stringify(injectedPanels), - }; + } as SavedObjectAttributes; + + if (injectedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...(attributes.controlGroupInput as SavedObjectAttributes), + panelsJSON: JSON.stringify(injectedState.controlGroupInput.panels), + }; + } + + return newAttributes; } function pre730ExtractReferences( diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 5851ffa045bc7..bfe53514969d7 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -22,6 +22,7 @@ import { } from './bwc/types'; import { GridData } from './embeddable/types'; +import { ControlGroupInput } from '../../presentation_util/common/controls/control_group/types'; export type PanelId = string; export type SavedObjectId = string; @@ -96,8 +97,22 @@ export type SavedDashboardPanel730ToLatest = Pick< // Making this interface because so much of the Container type from embeddable is tied up in public // Once that is all available from common, we should be able to move the dashboard_container type to our common as well + +export interface DashboardContainerControlGroupInput extends EmbeddableStateWithType { + panels: ControlGroupInput['panels']; + controlStyle: ControlGroupInput['controlStyle']; + id: string; +} + +export interface RawControlGroupAttributes { + controlStyle: ControlGroupInput['controlStyle']; + panelsJSON: string; + id: string; +} + export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { [panelId: string]: DashboardPanelState; }; + controlGroupInput?: DashboardContainerControlGroupInput; } diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss index bb95840676969..f71868b059159 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss @@ -5,3 +5,11 @@ .dshDashboardViewport-withMargins { width: 100%; } + +.dshDashboardViewport-controlGroup { + margin: 0 $euiSizeS 0 $euiSizeS; +} + +.dshDashboardEmptyScreen { + margin-top: $euiSizeS; +} diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 8b49ec41f7ddb..1f4cd3952e7a5 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -98,7 +98,7 @@ export class DashboardViewport extends React.Component -
+
; + export const getDefaultDashboardControlGroupInput = () => ({ controlStyle: 'oneLine' as ControlStyle, panels: {}, @@ -61,7 +66,7 @@ export const createAndSyncDashboardControlGroup = async ({ dashboardContainer.getInput().controlGroupInput ); - // Because dashboard container stores control group state, any control group changes need to be passed to dashboard container + // Because dashboard container stores control group state, certain control group changes need to be passed up dashboard container subscriptions.add( controlGroup.getInput$().subscribe(() => { const { panels, controlStyle } = controlGroup.getInput(); @@ -71,51 +76,62 @@ export const createAndSyncDashboardControlGroup = async ({ }) ); + const refetchDiffMethods: { + [key: string]: (a?: unknown, b?: unknown) => boolean; + } = { + filters: (a, b) => + compareFilters((a as Filter[]) ?? [], (b as Filter[]) ?? [], COMPARE_ALL_OPTIONS), + lastReloadRequestTime: deepEqual, + timeRange: deepEqual, + query: deepEqual, + }; + + // pass down any pieces of input needed to refetch or force refetch data for the controls subscriptions.add( dashboardContainer .getInput$() .pipe( - // skip updates when nothing of interest has changed. This prevents changes in lastReloadRequestTime from overwriting control group input distinctUntilChanged( (a, b) => - !( - ['controlGroupInput', 'filters', 'timeRange', 'query'] as Array< - keyof DashboardContainerInput - > - ) + !(Object.keys(refetchDiffMethods) as DashboardControlGroupCommonKeys[]) .map((key) => deepEqual(a[key], b[key])) .includes(false) ) ) .subscribe(() => { - let newInput: Partial = {}; - if (!isControlGroupInputEqual()) { - newInput = { ...dashboardContainer.getInput().controlGroupInput }; - } - // pass filters, query and time range down from - if ( - !compareFilters( - controlGroup.getInput().filters || [], - dashboardContainer.getInput().filters || [], - COMPARE_ALL_OPTIONS - ) - ) { - newInput.filters = dashboardContainer.getInput().filters; - } - if ( - !deepEqual(controlGroup.getInput().timeRange, dashboardContainer.getInput().timeRange) - ) { - newInput.timeRange = dashboardContainer.getInput().timeRange; - } - if (!deepEqual(controlGroup.getInput().query, dashboardContainer.getInput().query)) { - newInput.query = dashboardContainer.getInput().query; - } + const newInput: { [key: string]: unknown } = {}; + (Object.keys(refetchDiffMethods) as DashboardControlGroupCommonKeys[]).forEach((key) => { + if ( + !refetchDiffMethods[key]?.( + dashboardContainer.getInput()[key], + controlGroup.getInput()[key] + ) + ) { + newInput[key] = dashboardContainer.getInput()[key]; + } + }); if (Object.keys(newInput).length > 0) { controlGroup.updateInput(newInput); } }) ); + // dashboard may reset the control group input when discarding changes. Subscribe to these changes and update accordingly + subscriptions.add( + dashboardContainer + .getInput$() + .pipe(distinctUntilKeyChanged('controlGroupInput')) + .subscribe(() => { + if (!isControlGroupInputEqual()) { + if (!dashboardContainer.getInput().controlGroupInput) { + controlGroup.updateInput(getDefaultDashboardControlGroupInput()); + return; + } + controlGroup.updateInput({ ...dashboardContainer.getInput().controlGroupInput }); + } + }) + ); + // when control group outputs filters, force a refresh! subscriptions.add( controlGroup @@ -148,11 +164,16 @@ export const serializeControlGroupToDashboardSavedObject = ( dashboardState: DashboardState ) => { // only save to saved object if control group is not default - if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) return; - dashboardSavedObject.controlGroupInput = { - controlStyle: dashboardState.controlGroupInput?.controlStyle, - panelsJSON: JSON.stringify(dashboardState.controlGroupInput?.panels), - }; + if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) { + dashboardSavedObject.controlGroupInput = undefined; + return; + } + if (dashboardState.controlGroupInput) { + dashboardSavedObject.controlGroupInput = { + controlStyle: dashboardState.controlGroupInput.controlStyle, + panelsJSON: JSON.stringify(dashboardState.controlGroupInput.panels), + }; + } }; export const deserializeControlGroupFromDashboardSavedObject = ( diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts index 73949aa324da3..0624733ec559a 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import { merge, uniqBy } from 'lodash'; +import { uniqBy } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Observable, pipe } from 'rxjs'; +import { Observable, pipe, combineLatest } from 'rxjs'; import { distinctUntilChanged, switchMap, filter, mapTo, map } from 'rxjs/operators'; import { DashboardContainer } from '..'; @@ -31,10 +31,6 @@ export const syncDashboardIndexPatterns = ({ map((container: DashboardContainer): IndexPattern[] | undefined => { let panelIndexPatterns: IndexPattern[] = []; - if (container.controlGroup) { - panelIndexPatterns.push(...(container.controlGroup.getOutput().dataViews ?? [])); - } - Object.values(container.getChildIds()).forEach((id) => { const embeddableInstance = container.getChild(id); if (isErrorEmbeddable(embeddableInstance)) return; @@ -55,6 +51,9 @@ export const syncDashboardIndexPatterns = ({ ) { return; } + if (container.controlGroup) { + panelIndexPatterns.push(...(container.controlGroup.getOutput().dataViews ?? [])); + } return panelIndexPatterns; }), distinctUntilChanged((a, b) => @@ -82,7 +81,16 @@ export const syncDashboardIndexPatterns = ({ }) ); - return merge(dashboardContainer.getOutput$(), dashboardContainer.controlGroup?.getOutput$()) + if (dashboardContainer.controlGroup) { + return combineLatest([ + dashboardContainer.getOutput$(), + dashboardContainer.controlGroup.getOutput$(), + ]) + .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) + .subscribe(); + } + return dashboardContainer + .getOutput$() .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) .subscribe(); }; diff --git a/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts b/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts new file mode 100644 index 0000000000000..2da488acdc436 --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts @@ -0,0 +1,85 @@ +/* + * 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 { + EmbeddableInput, + EmbeddablePersistableStateService, + EmbeddableStateWithType, +} from '../../../../embeddable/common/types'; +import { ControlGroupInput, ControlPanelState } from './types'; +import { SavedObjectReference } from '../../../../../core/types'; + +type ControlGroupInputWithType = Partial & { type: string }; + +const getPanelStatePrefix = (state: ControlPanelState) => `${state.explicitInput.id}:`; + +export const createControlGroupInject = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | ControlGroupInputWithType; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + for (const [key, panel] of Object.entries(workingState.panels)) { + workingState.panels[key] = { ...panel }; + // Find the references for this panel + const prefix = getPanelStatePrefix(panel); + + const filteredReferences = references + .filter((reference) => reference.name.indexOf(prefix) === 0) + .map((reference) => ({ ...reference, name: reference.name.replace(prefix, '') })); + + const panelReferences = filteredReferences.length === 0 ? references : filteredReferences; + + const { type, ...injectedState } = persistableStateService.inject( + { ...workingState.panels[key].explicitInput, type: workingState.panels[key].type }, + panelReferences + ); + workingState.panels[key].explicitInput = injectedState as EmbeddableInput; + } + } + return workingState as EmbeddableStateWithType; + }; +}; + +export const createControlGroupExtract = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | ControlGroupInputWithType; + const references: SavedObjectReference[] = []; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + // Run every panel through the state service to get the nested references + for (const [key, panel] of Object.entries(workingState.panels)) { + const prefix = getPanelStatePrefix(panel); + + const { state: panelState, references: panelReferences } = persistableStateService.extract({ + ...panel.explicitInput, + type: panel.type, + }); + + // Map reference to its embeddable id for lookup in inject + const mappedReferences = panelReferences.map((reference) => ({ + ...reference, + name: `${prefix}${reference.name}`, + })); + + references.push(...mappedReferences); + + const { type, ...restOfState } = panelState; + workingState.panels[key].explicitInput = restOfState as EmbeddableInput; + } + } + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/presentation_util/common/controls/control_group/types.ts b/src/plugins/presentation_util/common/controls/control_group/types.ts new file mode 100644 index 0000000000000..da1cec0391102 --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_group/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { EmbeddableInput, PanelState } from '../../../../embeddable/common/types'; +import { ControlInput, ControlStyle, ControlWidth } from '../types'; + +export const CONTROL_GROUP_TYPE = 'control_group'; + +export interface ControlPanelState + extends PanelState { + order: number; + width: ControlWidth; +} + +export interface ControlsPanels { + [panelId: string]: ControlPanelState; +} + +export interface ControlGroupInput extends EmbeddableInput, ControlInput { + defaultControlWidth?: ControlWidth; + controlStyle: ControlStyle; + panels: ControlsPanels; +} diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts b/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts new file mode 100644 index 0000000000000..90390256325ae --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts @@ -0,0 +1,47 @@ +/* + * 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 { + EmbeddableStateWithType, + EmbeddablePersistableStateService, +} from '../../../../../embeddable/common/types'; +import { OptionsListEmbeddableInput } from './types'; +import { SavedObjectReference } from '../../../../../../core/types'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../../data_views/common'; + +type OptionsListInputWithType = Partial & { type: string }; +const dataViewReferenceName = 'optionsListDataView'; + +export const createOptionsListInject = (): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | OptionsListInputWithType; + references.forEach((reference) => { + if (reference.name === dataViewReferenceName) { + (workingState as OptionsListInputWithType).dataViewId = reference.id; + } + }); + return workingState as EmbeddableStateWithType; + }; +}; + +export const createOptionsListExtract = (): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | OptionsListInputWithType; + const references: SavedObjectReference[] = []; + + if ('dataViewId' in workingState) { + references.push({ + name: dataViewReferenceName, + type: DATA_VIEW_SAVED_OBJECT_TYPE, + id: workingState.dataViewId!, + }); + delete workingState.dataViewId; + } + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts b/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts new file mode 100644 index 0000000000000..9a6a96e861bed --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts @@ -0,0 +1,20 @@ +/* + * 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 { ControlInput } from '../../types'; + +export const OPTIONS_LIST_CONTROL = 'optionsListControl'; + +export interface OptionsListEmbeddableInput extends ControlInput { + fieldName: string; + dataViewId: string; + + selectedOptions?: string[]; + singleSelect?: boolean; + loading?: boolean; +} diff --git a/src/plugins/presentation_util/common/controls/index.ts b/src/plugins/presentation_util/common/controls/index.ts new file mode 100644 index 0000000000000..b01a242bdfa5f --- /dev/null +++ b/src/plugins/presentation_util/common/controls/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './control_group/types'; +export * from './control_types/options_list/types'; diff --git a/src/plugins/presentation_util/common/controls/types.ts b/src/plugins/presentation_util/common/controls/types.ts new file mode 100644 index 0000000000000..288324e30b47c --- /dev/null +++ b/src/plugins/presentation_util/common/controls/types.ts @@ -0,0 +1,28 @@ +/* + * 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/common'; +import { EmbeddableInput } from '../../../embeddable/common/types'; + +export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; +export type ControlStyle = 'twoLine' | 'oneLine'; + +export interface ParentIgnoreSettings { + ignoreFilters?: boolean; + ignoreQuery?: boolean; + ignoreTimerange?: boolean; +} + +export type ControlInput = EmbeddableInput & { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + controlStyle?: ControlStyle; + ignoreParentSettings?: ParentIgnoreSettings; +}; diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index c4c98888b886a..675366f3a3691 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -40,10 +40,10 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { isActive: false, isDisplayed: true, environments: ['kibana', 'browser', 'session'], - name: i18n.translate('presentationUtil.labs.enableDeferBelowFoldProjectName', { + name: i18n.translate('presentationUtil.labs.enableDashboardControlsProjectName', { defaultMessage: 'Enable dashboard controls', }), - description: i18n.translate('presentationUtil.labs.enableDeferBelowFoldProjectDescription', { + description: i18n.translate('presentationUtil.labs.enableDashboardControlsProjectDescription', { defaultMessage: 'Enables the controls system for dashboard, which allows dashboard authors to more easily build interactive elements for their users.', }), diff --git a/src/plugins/presentation_util/common/lib/index.ts b/src/plugins/presentation_util/common/lib/index.ts index 3fe90009ad8df..030780c130fa5 100644 --- a/src/plugins/presentation_util/common/lib/index.ts +++ b/src/plugins/presentation_util/common/lib/index.ts @@ -8,3 +8,4 @@ export * from './utils'; export * from './test_helpers'; +export * from '../controls'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 071a6d6702f71..d2e10dea1ee8a 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -88,7 +88,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con
); - const embeddableParentClassNames = classNames('controlFrame--control', { + const embeddableParentClassNames = classNames('controlFrame__control', { 'controlFrame--twoLine': controlStyle === 'twoLine', 'controlFrame--oneLine': controlStyle === 'oneLine', }); @@ -110,10 +110,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con > {embeddable && (
diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx index 77745d78ef479..98eaa08be8cf1 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -104,6 +104,7 @@ export const ControlGroup = () => { return ( { // Presentation Services Context diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 7536e1ed33b26..ce32b3e2eced2 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -26,7 +26,7 @@ import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS, DEFAULT_CONTROL_WIDTH, -} from '../control_group_constants'; +} from './editor_constants'; import { ControlGroupInput } from '../types'; import { pluginServices } from '../../../../services'; import { ControlStyle, ControlWidth } from '../../types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts b/src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts similarity index 87% rename from src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts rename to src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts index 3c22b1ffbcd23..812f794efc8c3 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import { ControlWidth } from '../types'; -import { ControlGroupStrings } from './control_group_strings'; - -export const CONTROL_GROUP_TYPE = 'control_group'; +import { ControlWidth } from '../../types'; +import { ControlGroupStrings } from '../control_group_strings'; export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx index 87ae86fcfc99c..06fb86f022253 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx @@ -21,15 +21,20 @@ import { take, } from 'rxjs/operators'; +import { + ControlGroupInput, + ControlGroupOutput, + ControlPanelState, + CONTROL_GROUP_TYPE, +} from '../types'; import { pluginServices } from '../../../../services'; import { DataView } from '../../../../../../data_views/public'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; import { Container, EmbeddableFactory } from '../../../../../../embeddable/public'; -import { ControlGroupInput, ControlGroupOutput, ControlPanelState } from '../types'; -import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; +import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; export class ControlGroupContainer extends Container< ControlInput, @@ -82,8 +87,8 @@ export class ControlGroupContainer extends Container< this.subscriptions.add( concat( - this.getOutput$().pipe(anyChildChangePipe, take(1)), // the first time filters are built, don't debounce so that initial filters are built immediately - this.getOutput$().pipe(anyChildChangePipe, debounceTime(10)) + 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) ); } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts index 61dbe9451f65f..1a323b6aa9e8d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts @@ -21,10 +21,14 @@ import { EmbeddableFactoryDefinition, ErrorEmbeddable, } from '../../../../../../embeddable/public'; -import { ControlGroupInput } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; -import { CONTROL_GROUP_TYPE } from '../control_group_constants'; +import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; import { ControlGroupContainer } from './control_group_container'; +import { EmbeddablePersistableStateService } from '../../../../../../embeddable/common'; +import { + createControlGroupExtract, + createControlGroupInject, +} from '../../../../../common/controls/control_group/control_group_persistable_state'; export type DashboardContainerFactory = EmbeddableFactory< ControlGroupInput, @@ -37,6 +41,8 @@ export class ControlGroupContainerFactory public readonly isContainerType = true; public readonly type = CONTROL_GROUP_TYPE; + constructor(private persistableStateService: EmbeddablePersistableStateService) {} + public isEditable = async () => false; public readonly getDisplayName = () => { @@ -60,4 +66,7 @@ export class ControlGroupContainerFactory ): Promise => { return new ControlGroupContainer(initialInput, parent); }; + + public inject = createControlGroupInject(this.persistableStateService); + public extract = createControlGroupExtract(this.persistableStateService); } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/index.ts b/src/plugins/presentation_util/public/components/controls/control_group/index.ts index 49ee4e46fce3a..e61006858b953 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/index.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/index.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -export { CONTROL_GROUP_TYPE } from './control_group_constants'; export { ControlGroupContainer } from './embeddable/control_group_container'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; export { ControlGroupContainerEmbeddable, ControlGroupInput, ControlGroupOutput } from './types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/presentation_util/public/components/controls/control_group/types.ts index fde2d995f3536..23d916e6f6785 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/types.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/types.ts @@ -6,19 +6,9 @@ * Side Public License, v 1. */ -import { CommonControlOutput, ControlStyle, ControlWidth, ControlInput } from '../types'; -import { - Container, - PanelState, - EmbeddableInput, - ContainerOutput, -} from '../../../../../embeddable/public'; - -export interface ControlGroupInput extends EmbeddableInput, ControlInput { - defaultControlWidth?: ControlWidth; - controlStyle: ControlStyle; - panels: ControlsPanels; -} +import { CommonControlOutput, ControlInput } from '../types'; +import { Container, ContainerOutput } from '../../../../../embeddable/public'; +import { ControlGroupInput } from '../../../../common/controls/control_group/types'; export type ControlGroupOutput = ContainerOutput & CommonControlOutput; @@ -28,12 +18,4 @@ export type ControlGroupContainerEmbeddable = Container< ControlGroupOutput >; -export interface ControlPanelState - extends PanelState { - order: number; - width: ControlWidth; -} - -export interface ControlsPanels { - [panelId: string]: ControlPanelState; -} +export * from '../../../../common/controls/control_group/types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 4878dcc882802..7253f2b060812 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -24,6 +24,7 @@ import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embedda import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; import { PresentationDataViewsService } from '../../../../services/data_views'; import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; import { PresentationDataService } from '../../../../services/data'; import { DataView } from '../../../../../../data_views/public'; import { optionsListReducers } from './options_list_reducers'; @@ -54,16 +55,6 @@ interface OptionsListDataFetchProps { const fieldMissingError = (fieldName: string) => new Error(`field ${fieldName} not found in index pattern`); -export const OPTIONS_LIST_CONTROL = 'optionsListControl'; -export interface OptionsListEmbeddableInput extends ControlInput { - fieldName: string; - dataViewId: string; - - selectedOptions?: string[]; - singleSelect?: boolean; - loading?: boolean; -} - export class OptionsListEmbeddable extends Embeddable { public readonly type = OPTIONS_LIST_CONTROL; public deferEmbeddableLoad = true; @@ -101,11 +92,12 @@ export class OptionsListEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ - fieldName: newInput.fieldName, + lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, - query: newInput.query, - filters: newInput.filters, + fieldName: newInput.fieldName, timeRange: newInput.timeRange, + filters: newInput.filters, + query: newInput.query, })), distinctUntilChanged(diffDataFetchProps) ); diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx index 109a01bf8c193..ef367faacb4ad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx @@ -7,23 +7,26 @@ */ import React from 'react'; -import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; + import { ControlEditorProps, GetControlEditorComponentProps, IEditableControlFactory, } from '../../types'; import { OptionsListEditor } from './options_list_editor'; +import { OptionsListEmbeddable } from './options_list_embeddable'; +import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; +import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; import { - OPTIONS_LIST_CONTROL, - OptionsListEmbeddable, - OptionsListEmbeddableInput, -} from './options_list_embeddable'; + createOptionsListExtract, + createOptionsListInject, +} from '../../../../../common/controls/control_types/options_list/options_list_persistable_state'; export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition, IEditableControlFactory { public type = OPTIONS_LIST_CONTROL; + public canCreateNew = () => false; constructor() {} @@ -47,4 +50,7 @@ export class OptionsListEmbeddableFactory public isEditable = () => Promise.resolve(false); public getDisplayName = () => 'Options List Control'; + + public inject = createOptionsListInject(); + public extract = createOptionsListExtract(); } diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 5dddd2632e519..12f4cb3264202 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -121,7 +121,7 @@ export const OptionsListPopover = ({ dispatch(selectOption(availableOption)); }} > - {availableOption} + {`${availableOption}`} ))} @@ -145,7 +145,7 @@ export const OptionsListPopover = ({ key={index} onClick={() => dispatch(deselectOption(availableOption))} > - {availableOption} + {`${availableOption}`} ))} {(!selectedOptions || selectedOptions.length === 0) && ( diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts new file mode 100644 index 0000000000000..06b6526f38db4 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../../../../../common/controls/control_types/options_list/types'; diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/presentation_util/public/components/controls/types.ts index a96cef3ca110e..48abc4ed97460 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -6,35 +6,11 @@ * Side Public License, v 1. */ -import { Filter, Query } from '@kbn/es-query'; -import { - EmbeddableFactory, - EmbeddableInput, - EmbeddableOutput, - IEmbeddable, -} from '../../../../embeddable/public'; -import { TimeRange } from '../../../../data/public'; +import { Filter } from '@kbn/es-query'; import { DataView } from '../../../../data_views/public'; +import { ControlInput } from '../../../common/controls/types'; +import { EmbeddableFactory, EmbeddableOutput, IEmbeddable } from '../../../../embeddable/public'; -export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; -export type ControlStyle = 'twoLine' | 'oneLine'; - -/** - * Generic control embeddable input and output - */ -export interface ParentIgnoreSettings { - ignoreFilters?: boolean; - ignoreQuery?: boolean; - ignoreTimerange?: boolean; -} - -export type ControlInput = EmbeddableInput & { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - controlStyle?: ControlStyle; - ignoreParentSettings?: ParentIgnoreSettings; -}; export interface CommonControlOutput { filters?: Filter[]; dataViews?: DataView[]; @@ -69,3 +45,8 @@ export type ControlEditorComponent = (props: ControlEditorProps) => JSX.Element; export interface ControlEditorProps { setValidState: (valid: boolean) => void; } + +/** + * Re-export control types from common + */ +export * from '../../../common/controls/types'; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index ac2d6fef61884..a2e7f119e4403 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -20,10 +20,11 @@ import { ReduxEmbeddableWrapperProps, } from './types'; import { + IContainer, IEmbeddable, EmbeddableInput, EmbeddableOutput, - IContainer, + isErrorEmbeddable, } from '../../../../embeddable/public'; import { getManagedEmbeddablesStore } from './generic_embeddable_store'; import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; @@ -170,25 +171,31 @@ const ReduxEmbeddableSync = state); const stateRef = useRef(currentState); + const destroyedRef = useRef(false); useEffect(() => { // When Embeddable Input changes, push differences to redux. const inputSubscription = embeddable .getInput$() .pipe(debounceTime(0)) // debounce input changes to ensure that when many updates are made in one render the latest wins out - .subscribe(() => { - const differences = diffInput(getExplicitInput(embeddable), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - if (stateContainsFilters(differences)) { - differences.filters = cleanFiltersForSerialize(differences.filters); + .subscribe( + () => { + const differences = diffInput(getExplicitInput(embeddable), stateRef.current); + if (differences && Object.keys(differences).length > 0) { + if (stateContainsFilters(differences)) { + differences.filters = cleanFiltersForSerialize(differences.filters); + } + dispatch(updateEmbeddableReduxState(differences)); } - dispatch(updateEmbeddableReduxState(differences)); - } - }); + }, + undefined, + () => (destroyedRef.current = true) // when input observer is complete, embeddable is destroyed + ); return () => inputSubscription.unsubscribe(); }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); useEffect(() => { + if (isErrorEmbeddable(embeddable) || destroyedRef.current) return; // When redux state changes, push differences to Embeddable Input. stateRef.current = currentState; const differences = diffInput(currentState, getExplicitInput(embeddable)); diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index 627b3f1057d23..1db25fbc206d2 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -16,11 +16,11 @@ import { PresentationUtilPluginSetup, PresentationUtilPluginStart, GetControlEditorComponent, - OPTIONS_LIST_CONTROL, - CONTROL_GROUP_TYPE, IEditableControlFactory, + OPTIONS_LIST_CONTROL, } from './types'; import { OptionsListEmbeddableFactory } from './components/controls/control_types/options_list'; +import { CONTROL_GROUP_TYPE } from '.'; export class PresentationUtilPlugin implements @@ -34,13 +34,18 @@ export class PresentationUtilPlugin private inlineEditors: { [key: string]: GetControlEditorComponent | undefined } = {}; public setup( - _coreSetup: CoreSetup, + _coreSetup: CoreSetup, _setupPlugins: PresentationUtilPluginSetupDeps ): PresentationUtilPluginSetup { - const { embeddable } = _setupPlugins; + _coreSetup.getStartServices().then(([coreStart, deps]) => { + // register control group embeddable factory + embeddable.registerEmbeddableFactory( + CONTROL_GROUP_TYPE, + new ControlGroupContainerFactory(deps.embeddable) + ); + }); - // register control group embeddable factory - embeddable.registerEmbeddableFactory(CONTROL_GROUP_TYPE, new ControlGroupContainerFactory()); + const { embeddable } = _setupPlugins; // create control type embeddable factories. const optionsListFactory = new OptionsListEmbeddableFactory(); diff --git a/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts b/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts new file mode 100644 index 0000000000000..17dcbbd249435 --- /dev/null +++ b/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts @@ -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 { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { EmbeddableRegistryDefinition } from '../../../../embeddable/server'; +import { CONTROL_GROUP_TYPE } from '../../../common/controls'; +import { + createControlGroupExtract, + createControlGroupInject, +} from '../../../common/controls/control_group/control_group_persistable_state'; + +export const controlGroupContainerPersistableStateServiceFactory = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddableRegistryDefinition => { + return { + id: CONTROL_GROUP_TYPE, + extract: createControlGroupExtract(persistableStateService), + inject: createControlGroupInject(persistableStateService), + }; +}; diff --git a/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts b/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts new file mode 100644 index 0000000000000..b9d69ea489274 --- /dev/null +++ b/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts @@ -0,0 +1,22 @@ +/* + * 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 { EmbeddableRegistryDefinition } from '../../../../../embeddable/server'; +import { OPTIONS_LIST_CONTROL } from '../../../../common/controls'; +import { + createOptionsListExtract, + createOptionsListInject, +} from '../../../../common/controls/control_types/options_list/options_list_persistable_state'; + +export const optionsListPersistableStateServiceFactory = (): EmbeddableRegistryDefinition => { + return { + id: OPTIONS_LIST_CONTROL, + extract: createOptionsListExtract(), + inject: createOptionsListInject(), + }; +}; diff --git a/src/plugins/presentation_util/server/plugin.ts b/src/plugins/presentation_util/server/plugin.ts index eb55373920625..2c52fa1f6c2d8 100644 --- a/src/plugins/presentation_util/server/plugin.ts +++ b/src/plugins/presentation_util/server/plugin.ts @@ -7,11 +7,24 @@ */ import { CoreSetup, Plugin } from 'kibana/server'; +import { EmbeddableSetup } from '../../embeddable/server'; +import { controlGroupContainerPersistableStateServiceFactory } from './controls/control_group/control_group_container_factory'; +import { optionsListPersistableStateServiceFactory } from './controls/control_types/options_list/options_list_embeddable_factory'; import { getUISettings } from './ui_settings'; -export class PresentationUtilPlugin implements Plugin { - public setup(core: CoreSetup) { +interface SetupDeps { + embeddable: EmbeddableSetup; +} + +export class PresentationUtilPlugin implements Plugin { + public setup(core: CoreSetup, plugins: SetupDeps) { core.uiSettings.register(getUISettings()); + + plugins.embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory()); + + plugins.embeddable.registerEmbeddableFactory( + controlGroupContainerPersistableStateServiceFactory(plugins.embeddable) + ); return {}; }