[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:
Devon Thomson 2021-08-20 10:38:59 -04:00 committed by GitHub
parent 0e39eeb8a9
commit ff17140179
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 213 additions and 121 deletions

View file

@ -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);

View file

@ -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
);
};
}

View file

@ -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>

View file

@ -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;
};