diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx index 2feb527ff916..4aff1ff4eee9 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx @@ -6,133 +6,63 @@ * Side Public License, v 1. */ -import React, { useMemo, useEffect, useState, useCallback, useRef } from 'react'; -import { debounceTime, tap } from 'rxjs/operators'; -import useMount from 'react-use/lib/useMount'; +import React, { useState } from 'react'; import classNames from 'classnames'; -import { Subject } from 'rxjs'; -import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectableOption } from '@elastic/eui'; -import { - OptionsListDataFetcher, - OptionsListEmbeddable, - OptionsListEmbeddableInput, -} from './options_list_embeddable'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectableOption } from '@elastic/eui'; +import { Subject } from 'rxjs'; import { OptionsListStrings } from './options_list_strings'; -import { InputControlOutput } from '../../embeddable/types'; import { OptionsListPopover } from './options_list_popover_component'; -import { withEmbeddableSubscription } from '../../../../../../embeddable/public'; import './options_list.scss'; +import { useStateObservable } from '../../use_state_observable'; -const toggleAvailableOptions = ( - indices: number[], - availableOptions: EuiSelectableOption[], - enabled: boolean -) => { - const newAvailableOptions = [...availableOptions]; - indices.forEach((index) => (newAvailableOptions[index].checked = enabled ? 'on' : undefined)); - return newAvailableOptions; -}; - -interface OptionsListProps { - input: OptionsListEmbeddableInput; - fetchData: OptionsListDataFetcher; +export interface OptionsListComponentState { + availableOptions?: EuiSelectableOption[]; + selectedOptionsString?: string; + selectedOptionsCount?: number; + twoLineLayout?: boolean; + searchString?: string; + loading: boolean; } -export const OptionsListInner = ({ input, fetchData }: OptionsListProps) => { - const [availableOptions, setAvailableOptions] = useState([]); - const selectedOptions = useRef>(new Set()); - - // raw search string is stored here so it is remembered when popover is closed. - const [searchString, setSearchString] = useState(''); - const [debouncedSearchString, setDebouncedSearchString] = useState(); - +export const OptionsListComponent = ({ + componentStateSubject, + typeaheadSubject, + updateOption, +}: { + componentStateSubject: Subject; + typeaheadSubject: Subject; + updateOption: (index: number) => void; +}) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [loading, setIsLoading] = useState(false); - - const typeaheadSubject = useMemo(() => new Subject(), []); - - useMount(() => { - typeaheadSubject - .pipe( - tap((rawSearchText) => setSearchString(rawSearchText)), - debounceTime(100) - ) - .subscribe((search) => setDebouncedSearchString(search)); - // default selections can be applied here... + const optionsListState = useStateObservable(componentStateSubject, { + loading: true, }); - const { indexPattern, timeRange, filters, field, query } = input; - useEffect(() => { - let canceled = false; - setIsLoading(true); - fetchData({ - search: debouncedSearchString, - indexPattern, - timeRange, - filters, - field, - query, - }).then((newOptions) => { - if (canceled) return; - setIsLoading(false); - // We now have new 'availableOptions', we need to ensure the previously selected options are still selected. - const enabledIndices: number[] = []; - selectedOptions.current?.forEach((selectedOption) => { - const optionIndex = newOptions.findIndex( - (availableOption) => availableOption.label === selectedOption - ); - if (optionIndex >= 0) enabledIndices.push(optionIndex); - }); - newOptions = toggleAvailableOptions(enabledIndices, newOptions, true); - setAvailableOptions(newOptions); - }); - return () => { - canceled = true; - }; - }, [indexPattern, timeRange, filters, field, query, debouncedSearchString, fetchData]); - - const updateItem = useCallback( - (index: number) => { - const item = availableOptions?.[index]; - if (!item) return; - - const toggleOff = availableOptions[index].checked === 'on'; - - const newAvailableOptions = toggleAvailableOptions([index], availableOptions, !toggleOff); - setAvailableOptions(newAvailableOptions); - - if (toggleOff) { - selectedOptions.current.delete(item.label); - } else { - selectedOptions.current.add(item.label); - } - }, - [availableOptions] - ); - - const selectedOptionsString = Array.from(selectedOptions.current).join( - OptionsListStrings.summary.getSeparator() - ); - const selectedOptionsLength = Array.from(selectedOptions.current).length; - - const { twoLineLayout } = input; + const { + selectedOptionsString, + selectedOptionsCount, + availableOptions, + twoLineLayout, + searchString, + loading, + } = optionsListState; const button = ( setIsPopoverOpen((openState) => !openState)} isSelected={isPopoverOpen} - numFilters={availableOptions.length} - hasActiveFilters={selectedOptionsLength > 0} - numActiveFilters={selectedOptionsLength} + numFilters={availableOptions?.length ?? 0} + hasActiveFilters={(selectedOptionsCount ?? 0) > 0} + numActiveFilters={selectedOptionsCount} > - {!selectedOptionsLength ? OptionsListStrings.summary.getPlaceholder() : selectedOptionsString} + {!selectedOptionsCount ? OptionsListStrings.summary.getPlaceholder() : selectedOptionsString} ); @@ -155,7 +85,7 @@ export const OptionsListInner = ({ input, fetchData }: OptionsListProps) => { > { ); }; - -export const OptionsListComponent = withEmbeddableSubscription< - OptionsListEmbeddableInput, - InputControlOutput, - OptionsListEmbeddable, - { fetchData: OptionsListDataFetcher } ->(OptionsListInner); diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx index 4dcc4a75dc1f..bdd3660606b7 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx @@ -8,12 +8,39 @@ 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 { OptionsListComponent } from './options_list_component'; +import { esFilters } from '../../../../../../data/public'; +import { OptionsListStrings } from './options_list_strings'; +import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; import { Embeddable } from '../../../../../../embeddable/public'; import { InputControlInput, InputControlOutput } from '../../embeddable/types'; +const toggleAvailableOptions = ( + indices: number[], + availableOptions: EuiSelectableOption[], + enabled?: boolean +) => { + const newAvailableOptions = [...availableOptions]; + indices.forEach((index) => (newAvailableOptions[index].checked = enabled ? 'on' : undefined)); + return newAvailableOptions; +}; + +const diffDataFetchProps = ( + current?: OptionsListDataFetchProps, + last?: OptionsListDataFetchProps +) => { + if (!current || !last) return false; + const { filters: currentFilters, ...currentWithoutFilters } = current; + const { filters: lastFilters, ...lastWithoutFilters } = last; + if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false; + if (!esFilters.compareFilters(lastFilters ?? [], currentFilters ?? [])) return false; + return true; +}; + interface OptionsListDataFetchProps { field: string; search?: string; @@ -32,6 +59,7 @@ export interface OptionsListEmbeddableInput extends InputControlInput { field: string; indexPattern: string; multiSelect: boolean; + defaultSelections?: string[]; } export class OptionsListEmbeddable extends Embeddable< OptionsListEmbeddableInput, @@ -42,6 +70,21 @@ export class OptionsListEmbeddable extends Embeddable< private node?: HTMLElement; private fetchData: OptionsListDataFetcher; + // internal state for this input control. + private selectedOptions: Set; + private typeaheadSubject: Subject = new Subject(); + private searchString: string = ''; + + private componentState: OptionsListComponentState; + private componentStateSubject$ = new Subject(); + private updateComponentState(changes: Partial) { + this.componentState = { + ...this.componentState, + ...changes, + }; + this.componentStateSubject$.next(this.componentState); + } + constructor( input: OptionsListEmbeddableInput, output: InputControlOutput, @@ -49,15 +92,118 @@ export class OptionsListEmbeddable extends Embeddable< ) { super(input, output); this.fetchData = fetchData; + + // populate default selections from input + this.selectedOptions = new Set(input.defaultSelections ?? []); + const { selectedOptionsCount, selectedOptionsString } = this.buildSelectedOptionsString(); + + // fetch available options when input changes or when search string has changed + const typeaheadPipe = this.typeaheadSubject.pipe( + 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) + ) + ); + 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 }); + }); + + this.componentState = { + loading: true, + selectedOptionsCount, + selectedOptionsString, + twoLineLayout: input.twoLineLayout, + }; + this.updateComponentState(this.componentState); } - reload = () => {}; + private fetchAvailableOptions = async () => { + this.updateComponentState({ loading: true }); + + const { indexPattern, timeRange, filters, field, query } = this.getInput(); + let 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 }); + }; + + 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 }); + }; + + 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(); + }; public render = (node: HTMLElement) => { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); } this.node = node; - ReactDOM.render(, node); + ReactDOM.render( + , + node + ); }; } diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx index cd558b99f9aa..4bfce9eb377e 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx @@ -23,14 +23,14 @@ import { OptionsListStrings } from './options_list_strings'; interface OptionsListPopoverProps { loading: boolean; typeaheadSubject: Subject; - searchString: string; - updateItem: (index: number) => void; - availableOptions: EuiSelectableOption[]; + searchString?: string; + updateOption: (index: number) => void; + availableOptions?: EuiSelectableOption[]; } export const OptionsListPopover = ({ loading, - updateItem, + updateOption, searchString, typeaheadSubject, availableOptions, @@ -53,7 +53,7 @@ export const OptionsListPopover = ({ updateItem(index)} + onClick={() => updateOption(index)} > {item.label} diff --git a/src/plugins/presentation_util/public/components/input_controls/use_state_observable.ts b/src/plugins/presentation_util/public/components/input_controls/use_state_observable.ts new file mode 100644 index 000000000000..c317f11979f5 --- /dev/null +++ b/src/plugins/presentation_util/public/components/input_controls/use_state_observable.ts @@ -0,0 +1,23 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { Observable } from 'rxjs'; + +export const useStateObservable = ( + stateObservable: Observable, + initialState: T +) => { + useEffect(() => { + const subscription = stateObservable.subscribe((newState) => setInnerState(newState)); + return () => subscription.unsubscribe(); + }, [stateObservable]); + const [innerState, setInnerState] = useState(initialState); + + return innerState; +};