[Input Controls] Options List Data Fetch In Embeddable (#108226)
Moved data fetching from react component into embeddable class. This cleans up the component, and allows for more accurate comparison before firing async requests
This commit is contained in:
parent
0e39eeb8a9
commit
ff17140179
|
@ -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<EuiSelectableOption[]>([]);
|
||||
const selectedOptions = useRef<Set<string>>(new Set<string>());
|
||||
|
||||
// raw search string is stored here so it is remembered when popover is closed.
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
const [debouncedSearchString, setDebouncedSearchString] = useState<string>();
|
||||
|
||||
export const OptionsListComponent = ({
|
||||
componentStateSubject,
|
||||
typeaheadSubject,
|
||||
updateOption,
|
||||
}: {
|
||||
componentStateSubject: Subject<OptionsListComponentState>;
|
||||
typeaheadSubject: Subject<string>;
|
||||
updateOption: (index: number) => void;
|
||||
}) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [loading, setIsLoading] = useState(false);
|
||||
|
||||
const typeaheadSubject = useMemo(() => new Subject<string>(), []);
|
||||
|
||||
useMount(() => {
|
||||
typeaheadSubject
|
||||
.pipe(
|
||||
tap((rawSearchText) => setSearchString(rawSearchText)),
|
||||
debounceTime(100)
|
||||
)
|
||||
.subscribe((search) => setDebouncedSearchString(search));
|
||||
// default selections can be applied here...
|
||||
const optionsListState = useStateObservable<OptionsListComponentState>(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 = (
|
||||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
className={classNames('optionsList--filterBtn', {
|
||||
'optionsList--filterBtnSingle': !twoLineLayout,
|
||||
'optionsList--filterBtnPlaceholder': !selectedOptionsLength,
|
||||
'optionsList--filterBtnPlaceholder': !selectedOptionsCount,
|
||||
})}
|
||||
onClick={() => 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}
|
||||
</EuiFilterButton>
|
||||
);
|
||||
|
||||
|
@ -155,7 +85,7 @@ export const OptionsListInner = ({ input, fetchData }: OptionsListProps) => {
|
|||
>
|
||||
<OptionsListPopover
|
||||
loading={loading}
|
||||
updateItem={updateItem}
|
||||
updateOption={updateOption}
|
||||
searchString={searchString}
|
||||
typeaheadSubject={typeaheadSubject}
|
||||
availableOptions={availableOptions}
|
||||
|
@ -164,10 +94,3 @@ export const OptionsListInner = ({ input, fetchData }: OptionsListProps) => {
|
|||
</EuiFilterGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const OptionsListComponent = withEmbeddableSubscription<
|
||||
OptionsListEmbeddableInput,
|
||||
InputControlOutput,
|
||||
OptionsListEmbeddable,
|
||||
{ fetchData: OptionsListDataFetcher }
|
||||
>(OptionsListInner);
|
||||
|
|
|
@ -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<string>;
|
||||
private typeaheadSubject: Subject<string> = new Subject<string>();
|
||||
private searchString: string = '';
|
||||
|
||||
private componentState: OptionsListComponentState;
|
||||
private componentStateSubject$ = new Subject<OptionsListComponentState>();
|
||||
private updateComponentState(changes: Partial<OptionsListComponentState>) {
|
||||
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<string>(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(<OptionsListComponent embeddable={this} fetchData={this.fetchData} />, node);
|
||||
ReactDOM.render(
|
||||
<OptionsListComponent
|
||||
updateOption={this.updateOption}
|
||||
typeaheadSubject={this.typeaheadSubject}
|
||||
componentStateSubject={this.componentStateSubject$}
|
||||
/>,
|
||||
node
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -23,14 +23,14 @@ import { OptionsListStrings } from './options_list_strings';
|
|||
interface OptionsListPopoverProps {
|
||||
loading: boolean;
|
||||
typeaheadSubject: Subject<string>;
|
||||
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 = ({
|
|||
<EuiFilterSelectItem
|
||||
checked={item.checked}
|
||||
key={index}
|
||||
onClick={() => updateItem(index)}
|
||||
onClick={() => updateOption(index)}
|
||||
>
|
||||
{item.label}
|
||||
</EuiFilterSelectItem>
|
||||
|
|
|
@ -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 = <T extends {} = {}>(
|
||||
stateObservable: Observable<T>,
|
||||
initialState: T
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const subscription = stateObservable.subscribe((newState) => setInnerState(newState));
|
||||
return () => subscription.unsubscribe();
|
||||
}, [stateObservable]);
|
||||
const [innerState, setInnerState] = useState<T>(initialState);
|
||||
|
||||
return innerState;
|
||||
};
|
Loading…
Reference in a new issue