[SearchBar] Improve rendering performance (#119189)

This commit is contained in:
Anton Dosov 2022-01-06 13:22:17 +01:00 committed by GitHub
parent eab0485fa3
commit 00d1ad30f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 729 additions and 542 deletions

View file

@ -287,7 +287,7 @@
"markdown-it": "^10.0.0",
"md5": "^2.1.0",
"mdast-util-to-hast": "10.0.1",
"memoize-one": "^5.0.0",
"memoize-one": "^6.0.0",
"mime": "^2.4.4",
"mime-types": "^2.1.27",
"mini-css-extract-plugin": "1.1.0",
@ -611,7 +611,6 @@
"@types/lz-string": "^1.3.34",
"@types/markdown-it": "^0.0.7",
"@types/md5": "^2.2.0",
"@types/memoize-one": "^4.1.0",
"@types/mime": "^2.0.1",
"@types/mime-types": "^2.1.0",
"@types/minimatch": "^2.0.29",

View file

@ -42,6 +42,10 @@ export class TimeHistory {
get() {
return this.history.get();
}
get$() {
return this.history.get$();
}
}
export type TimeHistoryContract = PublicMethodsOf<TimeHistory>;

View file

@ -9,6 +9,7 @@
import type { PublicMethodsOf } from '@kbn/utility-types';
import { TimefilterService, TimeHistoryContract, TimefilterContract } from '.';
import { Observable } from 'rxjs';
import { TimeRange } from '../../../common';
export type TimefilterServiceClientContract = PublicMethodsOf<TimefilterService>;
@ -43,6 +44,7 @@ const createSetupContractMock = () => {
const historyMock: jest.Mocked<TimeHistoryContract> = {
add: jest.fn(),
get: jest.fn(),
get$: jest.fn(() => new Observable<TimeRange[]>()),
};
const setupContract = {

View file

@ -40,7 +40,7 @@ interface Props {
timeRangeForSuggestionsOverride?: boolean;
}
function FilterBarUI(props: Props) {
const FilterBarUI = React.memo(function FilterBarUI(props: Props) {
const groupRef = useRef<HTMLDivElement>(null);
const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false);
const kibana = useKibana<IDataPluginServices>();
@ -226,6 +226,6 @@ function FilterBarUI(props: Props) {
</EuiFlexItem>
</EuiFlexGroup>
);
}
});
export const FilterBar = injectI18n(FilterBarUI);

View file

@ -32,7 +32,7 @@ export interface QueryLanguageSwitcherProps {
nonKqlModeHelpText?: string;
}
export function QueryLanguageSwitcher({
export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({
language,
anchorPosition,
onSelectLanguage,
@ -148,4 +148,4 @@ export function QueryLanguageSwitcher({
</div>
</EuiPopover>
);
}
});

View file

@ -11,9 +11,9 @@ import { mockPersistedLogFactory } from './query_string_input.test.mocks';
import React from 'react';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { EMPTY } from 'rxjs';
import QueryBarTopRow from './query_bar_top_row';
import { coreMock } from '../../../../../core/public/mocks';
import { dataPluginMock } from '../../mocks';
import { KibanaContextProvider } from 'src/plugins/kibana_react/public';
@ -26,6 +26,7 @@ const mockTimeHistory = {
get: () => {
return [];
},
get$: () => EMPTY,
};
startMock.uiSettings.get.mockImplementation((key: string) => {

View file

@ -8,7 +8,11 @@
import dateMath from '@elastic/datemath';
import classNames from 'classnames';
import React, { useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import useObservable from 'react-use/lib/useObservable';
import { EMPTY } from 'rxjs';
import { map } from 'rxjs/operators';
import {
EuiFlexGroup,
@ -17,9 +21,10 @@ import {
EuiFieldText,
prettyDuration,
EuiIconProps,
EuiSuperUpdateButton,
OnRefreshProps,
} from '@elastic/eui';
// @ts-ignore
import { EuiSuperUpdateButton, OnRefreshProps } from '@elastic/eui';
import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..';
import { useKibana, withKibana } from '../../../../kibana_react/public';
import QueryStringInputUI from './query_string_input';
@ -27,6 +32,14 @@ import { UI_SETTINGS } from '../../../common';
import { getQueryLog } from '../../query';
import type { PersistedLog } from '../../query';
import { NoDataPopover } from './no_data_popover';
import { shallowEqual } from '../../utils/shallow_equal';
const SuperDatePicker = React.memo(
EuiSuperDatePicker as any
) as unknown as typeof EuiSuperDatePicker;
const SuperUpdateButton = React.memo(
EuiSuperUpdateButton as any
) as unknown as typeof EuiSuperUpdateButton;
const QueryStringInput = withKibana(QueryStringInputUI);
@ -63,105 +76,15 @@ export interface QueryBarTopRowProps {
timeRangeForSuggestionsOverride?: boolean;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function QueryBarTopRow(props: QueryBarTopRowProps) {
const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false);
const [isQueryInputFocused, setIsQueryInputFocused] = useState(false);
const kibana = useKibana<IDataPluginServices>();
const { uiSettings, storage, appName } = kibana.services;
const queryLanguage = props.query && props.query.language;
const persistedLog: PersistedLog | undefined = React.useMemo(
() =>
queryLanguage && uiSettings && storage && appName
? getQueryLog(uiSettings!, storage, appName, queryLanguage)
: undefined,
[appName, queryLanguage, uiSettings, storage]
);
function onClickSubmitButton(event: React.MouseEvent<HTMLButtonElement>) {
if (persistedLog && props.query) {
persistedLog.add(props.query.query);
}
event.preventDefault();
onSubmit({ query: props.query, dateRange: getDateRange() });
}
function getDateRange() {
const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
return {
from: props.dateRangeFrom || defaultTimeSetting.from,
to: props.dateRangeTo || defaultTimeSetting.to,
};
}
function onQueryChange(query: Query) {
props.onChange({
query,
dateRange: getDateRange(),
});
}
function onChangeQueryInputFocus(isFocused: boolean) {
setIsQueryInputFocused(isFocused);
}
function onTimeChange({
start,
end,
isInvalid,
isQuickSelection,
}: {
start: string;
end: string;
isInvalid: boolean;
isQuickSelection: boolean;
}) {
setIsDateRangeInvalid(isInvalid);
const retVal = {
query: props.query,
dateRange: {
from: start,
to: end,
},
};
if (isQuickSelection) {
props.onSubmit(retVal);
} else {
props.onChange(retVal);
}
}
function onRefresh({ start, end }: OnRefreshProps) {
const retVal = {
dateRange: {
from: start,
to: end,
},
};
if (props.onRefresh) {
props.onRefresh(retVal);
}
}
function onSubmit({ query, dateRange }: { query?: Query; dateRange: TimeRange }) {
if (props.timeHistory) {
props.timeHistory.add(dateRange);
}
props.onSubmit({ query, dateRange });
}
function onInputSubmit(query: Query) {
onSubmit({
query,
dateRange: getDateRange(),
});
}
const SharingMetaFields = React.memo(function SharingMetaFields({
from,
to,
dateFormat,
}: {
from: string;
to: string;
dateFormat: string;
}) {
function toAbsoluteString(value: string, roundUp = false) {
const valueAsMoment = dateMath.parse(value, { roundUp });
if (!valueAsMoment) {
@ -170,158 +93,311 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) {
return valueAsMoment.toISOString();
}
function renderQueryInput() {
if (!shouldRenderQueryInput()) return;
const dateRangePretty = prettyDuration(
toAbsoluteString(from),
toAbsoluteString(to),
[],
dateFormat
);
return (
<EuiFlexItem>
<QueryStringInput
disableAutoFocus={props.disableAutoFocus}
indexPatterns={props.indexPatterns!}
prepend={props.prepend}
query={props.query!}
screenTitle={props.screenTitle}
onChange={onQueryChange}
onChangeQueryInputFocus={onChangeQueryInputFocus}
onSubmit={onInputSubmit}
persistedLog={persistedLog}
dataTestSubj={props.dataTestSubj}
placeholder={props.placeholder}
isClearable={props.isClearable}
iconType={props.iconType}
nonKqlMode={props.nonKqlMode}
nonKqlModeHelpText={props.nonKqlModeHelpText}
timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride}
/>
</EuiFlexItem>
);
}
return (
<div
data-shared-timefilter-duration={dateRangePretty}
data-test-subj="dataSharedTimefilterDuration"
/>
);
});
function renderSharingMetaFields() {
const { from, to } = getDateRange();
const dateRangePretty = prettyDuration(
toAbsoluteString(from),
toAbsoluteString(to),
[],
uiSettings.get('dateFormat')
);
return (
<div
data-shared-timefilter-duration={dateRangePretty}
data-test-subj="dataSharedTimefilterDuration"
/>
);
}
export const QueryBarTopRow = React.memo(
function QueryBarTopRow(props: QueryBarTopRowProps) {
const { showQueryInput = true, showDatePicker = true, showAutoRefreshOnly = false } = props;
function shouldRenderDatePicker(): boolean {
return Boolean(props.showDatePicker || props.showAutoRefreshOnly);
}
const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false);
const [isQueryInputFocused, setIsQueryInputFocused] = useState(false);
function shouldRenderQueryInput(): boolean {
return Boolean(props.showQueryInput && props.indexPatterns && props.query && storage);
}
const kibana = useKibana<IDataPluginServices>();
const { uiSettings, storage, appName } = kibana.services;
function renderUpdateButton() {
const button = props.customSubmitButton ? (
React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton })
) : (
<EuiSuperUpdateButton
needsUpdate={props.isDirty}
isDisabled={isDateRangeInvalid}
isLoading={props.isLoading}
onClick={onClickSubmitButton}
fill={props.fillSubmitButton}
data-test-subj="querySubmitButton"
/>
const queryLanguage = props.query && props.query.language;
const queryRef = useRef<Query | undefined>(props.query);
queryRef.current = props.query;
const persistedLog: PersistedLog | undefined = React.useMemo(
() =>
queryLanguage && uiSettings && storage && appName
? getQueryLog(uiSettings!, storage, appName, queryLanguage)
: undefined,
[appName, queryLanguage, uiSettings, storage]
);
if (!shouldRenderDatePicker()) {
return button;
function getDateRange() {
const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
return {
from: props.dateRangeFrom || defaultTimeSetting.from,
to: props.dateRangeTo || defaultTimeSetting.to,
};
}
return (
<NoDataPopover storage={storage} showNoDataPopover={props.indicateNoData}>
<EuiFlexGroup responsive={false} gutterSize="s">
{renderDatePicker()}
<EuiFlexItem grow={false}>{button}</EuiFlexItem>
</EuiFlexGroup>
</NoDataPopover>
);
}
const currentDateRange = getDateRange();
const dateRangeRef = useRef<{ from: string; to: string }>(currentDateRange);
dateRangeRef.current = currentDateRange;
function renderDatePicker() {
if (!shouldRenderDatePicker()) {
return null;
}
const propsOnSubmit = props.onSubmit;
let recentlyUsedRanges;
if (props.timeHistory) {
recentlyUsedRanges = props.timeHistory
.get()
.map(({ from, to }: { from: string; to: string }) => {
return {
start: from,
end: to,
};
});
}
const commonlyUsedRanges = uiSettings!
.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES)
.map(({ from, to, display }: { from: string; to: string; display: string }) => {
const toRecentlyUsedRanges = (ranges: TimeRange[]) =>
ranges.map(({ from, to }: { from: string; to: string }) => {
return {
start: from,
end: to,
label: display,
};
});
const timeHistory = props.timeHistory;
const timeHistory$ = useMemo(
() => timeHistory?.get$().pipe(map(toRecentlyUsedRanges)) ?? EMPTY,
[timeHistory]
);
const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused,
const recentlyUsedRanges = useObservable(
timeHistory$,
toRecentlyUsedRanges(timeHistory?.get() ?? [])
);
const [commonlyUsedRanges] = useState(() => {
return (
uiSettings
?.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES)
?.map(({ from, to, display }: { from: string; to: string; display: string }) => {
return {
start: from,
end: to,
label: display,
};
}) ?? []
);
});
const onSubmit = useCallback(
({ query, dateRange }: { query?: Query; dateRange: TimeRange }) => {
if (timeHistory) {
timeHistory.add(dateRange);
}
propsOnSubmit({ query, dateRange });
},
[timeHistory, propsOnSubmit]
);
const onClickSubmitButton = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (persistedLog && queryRef.current) {
persistedLog.add(queryRef.current.query);
}
event.preventDefault();
onSubmit({
query: queryRef.current,
dateRange: dateRangeRef.current,
});
},
[persistedLog, onSubmit]
);
const propsOnChange = props.onChange;
const onQueryChange = useCallback(
(query: Query) => {
return propsOnChange({
query,
dateRange: dateRangeRef.current,
});
},
[propsOnChange]
);
const onChangeQueryInputFocus = useCallback((isFocused: boolean) => {
setIsQueryInputFocused(isFocused);
}, []);
const onTimeChange = useCallback(
({
start,
end,
isInvalid,
isQuickSelection,
}: {
start: string;
end: string;
isInvalid: boolean;
isQuickSelection: boolean;
}) => {
setIsDateRangeInvalid(isInvalid);
const retVal = {
query: queryRef.current,
dateRange: {
from: start,
to: end,
},
};
if (isQuickSelection) {
onSubmit(retVal);
} else {
propsOnChange(retVal);
}
},
[propsOnChange, onSubmit]
);
const propsOnRefresh = props.onRefresh;
const onRefresh = useCallback(
({ start, end }: OnRefreshProps) => {
const retVal = {
dateRange: {
from: start,
to: end,
},
};
if (propsOnRefresh) {
propsOnRefresh(retVal);
}
},
[propsOnRefresh]
);
const onInputSubmit = useCallback(
(query: Query) => {
onSubmit({
query,
dateRange: dateRangeRef.current,
});
},
[onSubmit]
);
function shouldRenderQueryInput(): boolean {
return Boolean(showQueryInput && props.indexPatterns && props.query && storage);
}
function shouldRenderDatePicker(): boolean {
return Boolean(showDatePicker || showAutoRefreshOnly);
}
function renderDatePicker() {
if (!shouldRenderDatePicker()) {
return null;
}
const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused,
});
return (
<EuiFlexItem className={wrapperClasses}>
<SuperDatePicker
start={props.dateRangeFrom}
end={props.dateRangeTo}
isPaused={props.isRefreshPaused}
refreshInterval={props.refreshInterval}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
onRefreshChange={props.onRefreshChange}
showUpdateButton={false}
recentlyUsedRanges={recentlyUsedRanges}
commonlyUsedRanges={commonlyUsedRanges}
dateFormat={uiSettings.get('dateFormat')}
isAutoRefreshOnly={showAutoRefreshOnly}
className="kbnQueryBar__datePicker"
/>
</EuiFlexItem>
);
}
function renderUpdateButton() {
const button = props.customSubmitButton ? (
React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton })
) : (
<SuperUpdateButton
needsUpdate={props.isDirty}
isDisabled={isDateRangeInvalid}
isLoading={props.isLoading}
onClick={onClickSubmitButton}
fill={props.fillSubmitButton}
data-test-subj="querySubmitButton"
/>
);
if (!shouldRenderDatePicker()) {
return button;
}
return (
<NoDataPopover storage={storage} showNoDataPopover={props.indicateNoData}>
<EuiFlexGroup responsive={false} gutterSize="s">
{renderDatePicker()}
<EuiFlexItem grow={false}>{button}</EuiFlexItem>
</EuiFlexGroup>
</NoDataPopover>
);
}
function renderQueryInput() {
if (!shouldRenderQueryInput()) return;
return (
<EuiFlexItem>
<QueryStringInput
disableAutoFocus={props.disableAutoFocus}
indexPatterns={props.indexPatterns!}
prepend={props.prepend}
query={props.query!}
screenTitle={props.screenTitle}
onChange={onQueryChange}
onChangeQueryInputFocus={onChangeQueryInputFocus}
onSubmit={onInputSubmit}
persistedLog={persistedLog}
dataTestSubj={props.dataTestSubj}
placeholder={props.placeholder}
isClearable={props.isClearable}
iconType={props.iconType}
nonKqlMode={props.nonKqlMode}
nonKqlModeHelpText={props.nonKqlModeHelpText}
timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride}
/>
</EuiFlexItem>
);
}
const classes = classNames('kbnQueryBar', {
'kbnQueryBar--withDatePicker': showDatePicker,
});
return (
<EuiFlexItem className={wrapperClasses}>
<EuiSuperDatePicker
start={props.dateRangeFrom}
end={props.dateRangeTo}
isPaused={props.isRefreshPaused}
refreshInterval={props.refreshInterval}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
onRefreshChange={props.onRefreshChange}
showUpdateButton={false}
recentlyUsedRanges={recentlyUsedRanges}
commonlyUsedRanges={commonlyUsedRanges}
dateFormat={uiSettings!.get('dateFormat')}
isAutoRefreshOnly={props.showAutoRefreshOnly}
className="kbnQueryBar__datePicker"
<EuiFlexGroup
className={classes}
responsive={!!showDatePicker}
gutterSize="s"
justifyContent="flexEnd"
>
{renderQueryInput()}
<SharingMetaFields
from={currentDateRange.from}
to={currentDateRange.to}
dateFormat={uiSettings.get('dateFormat')}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{renderUpdateButton()}</EuiFlexItem>
</EuiFlexGroup>
);
},
({ query: prevQuery, ...prevProps }, { query: nextQuery, ...nextProps }) => {
let isQueryEqual = true;
if (prevQuery !== nextQuery) {
if (!deepEqual(prevQuery, nextQuery)) {
isQueryEqual = false;
}
}
return isQueryEqual && shallowEqual(prevProps, nextProps);
}
);
const classes = classNames('kbnQueryBar', {
'kbnQueryBar--withDatePicker': props.showDatePicker,
});
return (
<EuiFlexGroup
className={classes}
responsive={!!props.showDatePicker}
gutterSize="s"
justifyContent="flexEnd"
>
{renderQueryInput()}
{renderSharingMetaFields()}
<EuiFlexItem grow={false}>{renderUpdateButton()}</EuiFlexItem>
</EuiFlexGroup>
);
}
QueryBarTopRow.defaultProps = {
showQueryInput: true,
showDatePicker: true,
showAutoRefreshOnly: false,
};
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default QueryBarTopRow;

View file

@ -6,31 +6,31 @@
* Side Public License, v 1.
*/
import React, { Component, RefObject, createRef } from 'react';
import React, { PureComponent } from 'react';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import {
EuiTextArea,
EuiOutsideClickDetector,
PopoverAnchorPosition,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiLink,
htmlIdGenerator,
EuiPortal,
EuiIcon,
EuiIconProps,
EuiLink,
EuiOutsideClickDetector,
EuiPortal,
EuiTextArea,
htmlIdGenerator,
PopoverAnchorPosition,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { debounce, compact, isEqual, isFunction } from 'lodash';
import { compact, debounce, isEqual, isFunction } from 'lodash';
import { Toast } from 'src/core/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { IDataPluginServices, IIndexPattern, Query } from '../..';
import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete';
import { KibanaReactContextValue, toMountPoint } from '../../../../kibana_react/public';
import { fetchIndexPatterns } from './fetch_index_patterns';
import { QueryLanguageSwitcher } from './language_switcher';
@ -38,7 +38,8 @@ import { getQueryLog, matchPairs, toUser, fromUser } from '../../query';
import type { PersistedLog } from '../../query';
import type { SuggestionsListSize } from '../typeahead/suggestions_component';
import { SuggestionsComponent } from '..';
import { KIBANA_USER_QUERY_LANGUAGE_KEY, getFieldSubtypeNested } from '../../../common';
import { getFieldSubtypeNested, KIBANA_USER_QUERY_LANGUAGE_KEY } from '../../../common';
import { onRaf } from '../utils';
export interface QueryStringInputProps {
indexPatterns: Array<IIndexPattern | string>;
@ -96,7 +97,11 @@ interface State {
selectionStart: number | null;
selectionEnd: number | null;
indexPatterns: IIndexPattern[];
queryBarRect: DOMRect | undefined;
/**
* Part of state because passed down to child components
*/
queryBarInputDiv: HTMLDivElement | null;
}
const KEY_CODES = {
@ -113,7 +118,7 @@ const KEY_CODES = {
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default class QueryStringInputUI extends Component<Props, State> {
export default class QueryStringInputUI extends PureComponent<Props, State> {
static defaultProps = {
storageKey: KIBANA_USER_QUERY_LANGUAGE_KEY,
};
@ -126,7 +131,7 @@ export default class QueryStringInputUI extends Component<Props, State> {
selectionStart: null,
selectionEnd: null,
indexPatterns: [],
queryBarRect: undefined,
queryBarInputDiv: null,
};
public inputRef: HTMLTextAreaElement | null = null;
@ -140,7 +145,6 @@ export default class QueryStringInputUI extends Component<Props, State> {
this.services.appName
);
private componentIsUnmounting = false;
private queryBarInputDivRefInstance: RefObject<HTMLDivElement> = createRef();
/**
* If any element within the container is currently focused
@ -280,7 +284,9 @@ export default class QueryStringInputUI extends Component<Props, State> {
suggestionLimit: 50,
});
this.onChange({ query: value, language: this.props.query.language });
if (this.props.query.query !== value) {
this.onChange({ query: value, language: this.props.query.language });
}
};
private onInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -318,10 +324,16 @@ export default class QueryStringInputUI extends Component<Props, State> {
const { value, selectionStart, selectionEnd } = target;
const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => {
this.onQueryStringChange(query);
this.setState({
selectionStart: newSelectionStart,
selectionEnd: newSelectionEnd,
});
if (
this.inputRef?.selectionStart !== newSelectionStart ||
this.inputRef?.selectionEnd !== newSelectionEnd
) {
this.setState({
selectionStart: newSelectionStart,
selectionEnd: newSelectionEnd,
});
}
};
switch (event.keyCode) {
@ -576,7 +588,7 @@ export default class QueryStringInputUI extends Component<Props, State> {
: getQueryLog(uiSettings, storage, appName, this.props.query.language);
};
public onMouseEnterSuggestion = (index: number) => {
public onMouseEnterSuggestion = (suggestion: QuerySuggestion, index: number) => {
this.setState({ index });
};
@ -590,13 +602,9 @@ export default class QueryStringInputUI extends Component<Props, State> {
this.initPersistedLog();
this.fetchIndexPatterns();
this.handleListUpdate();
this.handleAutoHeight();
window.addEventListener('resize', this.handleAutoHeight);
window.addEventListener('scroll', this.handleListUpdate, {
passive: true, // for better performance as we won't call preventDefault
capture: true, // scroll events don't bubble, they must be captured instead
});
}
public componentDidUpdate(prevProps: Props) {
@ -621,11 +629,12 @@ export default class QueryStringInputUI extends Component<Props, State> {
selectionStart: null,
selectionEnd: null,
});
if (document.activeElement !== null && document.activeElement.id === this.textareaId) {
this.handleAutoHeight();
} else {
this.handleRemoveHeight();
}
}
if (document.activeElement !== null && document.activeElement.id === this.textareaId) {
this.handleAutoHeight();
} else {
this.handleRemoveHeight();
}
}
@ -634,47 +643,35 @@ export default class QueryStringInputUI extends Component<Props, State> {
if (this.updateSuggestions.cancel) this.updateSuggestions.cancel();
this.componentIsUnmounting = true;
window.removeEventListener('resize', this.handleAutoHeight);
window.removeEventListener('scroll', this.handleListUpdate, { capture: true });
}
handleListUpdate = () => {
if (this.componentIsUnmounting) return;
return this.setState({
queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(),
});
};
handleAutoHeight = () => {
handleAutoHeight = onRaf(() => {
if (this.inputRef !== null && document.activeElement === this.inputRef) {
this.inputRef.classList.add('kbnQueryBar__textarea--autoHeight');
this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important');
}
this.handleListUpdate();
};
});
handleRemoveHeight = () => {
handleRemoveHeight = onRaf(() => {
if (this.inputRef !== null) {
this.inputRef.style.removeProperty('height');
this.inputRef.classList.remove('kbnQueryBar__textarea--autoHeight');
}
};
});
handleBlurHeight = () => {
handleBlurHeight = onRaf(() => {
if (this.inputRef !== null) {
this.handleRemoveHeight();
this.inputRef.scrollTop = 0;
}
};
});
handleOnFocus = () => {
if (this.props.onChangeQueryInputFocus) {
this.props.onChangeQueryInputFocus(true);
}
requestAnimationFrame(() => {
this.handleAutoHeight();
});
this.handleAutoHeight();
};
public render() {
@ -700,16 +697,7 @@ export default class QueryStringInputUI extends Component<Props, State> {
);
return (
<div
className={containerClassName}
onFocus={(e) => {
this.isFocusWithin = true;
}}
onBlur={(e) => {
this.isFocusWithin = false;
this.scheduleOnInputBlur();
}}
>
<div className={containerClassName} onFocus={this.onFocusWithin} onBlur={this.onBlurWithin}>
{this.props.prepend}
<EuiOutsideClickDetector onOutsideClick={this.onOutsideClick}>
<div
@ -723,11 +711,7 @@ export default class QueryStringInputUI extends Component<Props, State> {
aria-expanded={this.state.isSuggestionsVisible}
data-skip-axe="aria-required-children"
>
<div
role="search"
className={inputWrapClassName}
ref={this.queryBarInputDivRefInstance}
>
<div role="search" className={inputWrapClassName} ref={this.assignQueryInputDivRef}>
<EuiTextArea
placeholder={
this.props.placeholder ||
@ -749,11 +733,7 @@ export default class QueryStringInputUI extends Component<Props, State> {
autoFocus={
this.props.onChangeQueryInputFocus ? false : !this.props.disableAutoFocus
}
inputRef={(node: any) => {
if (node) {
this.inputRef = node;
}
}}
inputRef={this.assignInputRef}
autoComplete="off"
spellCheck={false}
aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', {
@ -810,8 +790,8 @@ export default class QueryStringInputUI extends Component<Props, State> {
onClick={this.onClickSuggestion}
onMouseEnter={this.onMouseEnterSuggestion}
loadMore={this.increaseLimit}
queryBarRect={this.state.queryBarRect}
size={this.props.size}
inputContainer={this.state.queryBarInputDiv}
/>
</EuiPortal>
</div>
@ -858,4 +838,21 @@ export default class QueryStringInputUI extends Component<Props, State> {
return formattedNewQueryString;
}
}
private assignInputRef = (node: HTMLTextAreaElement | null) => {
this.inputRef = node;
};
private assignQueryInputDivRef = (node: HTMLDivElement | null) => {
this.setState({ queryBarInputDiv: node });
};
private onFocusWithin = () => {
this.isFocusWithin = true;
};
private onBlurWithin = () => {
this.isFocusWithin = false;
this.scheduleOnInputBlur();
};
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Subscription } from 'rxjs';
import { Query } from '../../..';
import type { QueryStringContract } from '../../../query/query_string';
@ -36,5 +36,13 @@ export const useQueryStringManager = (props: UseQueryStringProps) => {
};
}, [props.queryStringManager]);
return { query };
const stableQuery = useMemo(
() => ({
language: query.language,
query: query.query,
}),
[query.language, query.query]
);
return { query: stableQuery };
};

View file

@ -12,6 +12,7 @@ import classNames from 'classnames';
import React, { Component } from 'react';
import { get, isEqual } from 'lodash';
import { EuiIconProps } from '@elastic/eui';
import memoizeOne from 'memoize-one';
import { METRIC_TYPE } from '@kbn/analytics';
import { Query, Filter } from '@kbn/es-query';
@ -186,6 +187,10 @@ class SearchBarUI extends Component<SearchBarProps, State> {
);
};
componentWillUnmount() {
this.renderSavedQueryManagement.clear();
}
private shouldRenderQueryBar() {
const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly;
const showQueryInput =
@ -343,18 +348,6 @@ class SearchBarUI extends Component<SearchBarProps, State> {
};
public render() {
const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && (
<SavedQueryManagementComponent
showSaveQuery={this.props.showSaveQuery}
loadedSavedQuery={this.props.savedQuery}
onSave={this.onInitiateSave}
onSaveAsNew={this.onInitiateSaveNew}
onLoad={this.onLoadSavedQuery}
savedQueryService={this.savedQueryService}
onClearSavedQuery={this.props.onClearSavedQuery}
/>
);
const timeRangeForSuggestionsOverride = this.props.showDatePicker ? undefined : false;
let queryBar;
@ -368,7 +361,15 @@ class SearchBarUI extends Component<SearchBarProps, State> {
indexPatterns={this.props.indexPatterns}
isLoading={this.props.isLoading}
fillSubmitButton={this.props.fillSubmitButton || false}
prepend={this.props.showFilterBar ? savedQueryManagement : undefined}
prepend={
this.props.showFilterBar && this.state.query
? this.renderSavedQueryManagement(
this.props.onClearSavedQuery,
this.props.showSaveQuery,
this.props.savedQuery
)
: undefined
}
showDatePicker={this.props.showDatePicker}
dateRangeFrom={this.state.dateRangeFrom}
dateRangeTo={this.state.dateRangeTo}
@ -447,6 +448,28 @@ class SearchBarUI extends Component<SearchBarProps, State> {
</div>
);
}
private renderSavedQueryManagement = memoizeOne(
(
onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'],
showSaveQuery: SearchBarOwnProps['showSaveQuery'],
savedQuery: SearchBarOwnProps['savedQuery']
) => {
const savedQueryManagement = onClearSavedQuery && (
<SavedQueryManagementComponent
showSaveQuery={showSaveQuery}
loadedSavedQuery={savedQuery}
onSave={this.onInitiateSave}
onSaveAsNew={this.onInitiateSaveNew}
onLoad={this.onLoadSavedQuery}
savedQueryService={this.savedQueryService}
onClearSavedQuery={onClearSavedQuery}
/>
);
return savedQueryManagement;
}
);
}
// Needed for React.lazy

View file

@ -1,129 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuggestionsComponent Passing the index should control which suggestion is selected 1`] = `
<styled.div
queryBarRect={
Object {
"top": 0,
}
}
verticalListPosition="bottom: 768px;"
>
<div
className="kbnTypeahead"
>
<div
className="kbnTypeahead__popover kbnTypeahead__popover--top"
>
<div
id="kbnTypeahead__items"
onScroll={[Function]}
role="listbox"
>
<SuggestionComponent
ariaId="suggestion-0"
index={0}
innerRef={[Function]}
key="value - as promised, not helpful"
onClick={[Function]}
onMouseEnter={[Function]}
selected={false}
shouldDisplayDescription={false}
suggestion={
Object {
"description": "This is not a helpful suggestion",
"end": 0,
"start": 42,
"text": "as promised, not helpful",
"type": "value",
}
}
/>
<SuggestionComponent
ariaId="suggestion-1"
index={1}
innerRef={[Function]}
key="field - yep"
onClick={[Function]}
onMouseEnter={[Function]}
selected={true}
shouldDisplayDescription={false}
suggestion={
Object {
"description": "This is another unhelpful suggestion",
"end": 0,
"start": 42,
"text": "yep",
"type": "field",
}
}
/>
</div>
</div>
</div>
</styled.div>
`;
exports[`SuggestionsComponent Should display given suggestions if the show prop is true 1`] = `
<styled.div
queryBarRect={
Object {
"top": 0,
}
}
verticalListPosition="bottom: 768px;"
>
<div
className="kbnTypeahead"
>
<div
className="kbnTypeahead__popover kbnTypeahead__popover--top"
>
<div
id="kbnTypeahead__items"
onScroll={[Function]}
role="listbox"
>
<SuggestionComponent
ariaId="suggestion-0"
index={0}
innerRef={[Function]}
key="value - as promised, not helpful"
onClick={[Function]}
onMouseEnter={[Function]}
selected={true}
shouldDisplayDescription={false}
suggestion={
Object {
"description": "This is not a helpful suggestion",
"end": 0,
"start": 42,
"text": "as promised, not helpful",
"type": "value",
}
}
/>
<SuggestionComponent
ariaId="suggestion-1"
index={1}
innerRef={[Function]}
key="field - yep"
onClick={[Function]}
onMouseEnter={[Function]}
selected={false}
shouldDisplayDescription={false}
suggestion={
Object {
"description": "This is another unhelpful suggestion",
"end": 0,
"start": 42,
"text": "yep",
"type": "field",
}
}
/>
</div>
</div>
</div>
</styled.div>
`;

View file

@ -59,9 +59,10 @@ describe('SuggestionComponent', () => {
});
it('Should call innerRef with a reference to the root div element', () => {
const innerRefCallback = (ref: HTMLDivElement) => {
const innerRefCallback = (index: number, ref: HTMLDivElement) => {
expect(ref.className).toBe('kbnTypeahead__item');
expect(ref.id).toBe('suggestion-1');
expect(index).toBe(0);
};
mount(

View file

@ -8,9 +8,9 @@
import { EuiIcon } from '@elastic/eui';
import classNames from 'classnames';
import React from 'react';
import React, { useCallback } from 'react';
import { QuerySuggestion } from '../../autocomplete';
import { SuggestionOnClick } from './types';
import { SuggestionOnClick, SuggestionOnMouseEnter } from './types';
function getEuiIconType(type: string) {
switch (type) {
@ -31,16 +31,32 @@ function getEuiIconType(type: string) {
interface Props {
onClick: SuggestionOnClick;
onMouseEnter: () => void;
onMouseEnter: SuggestionOnMouseEnter;
selected: boolean;
index: number;
suggestion: QuerySuggestion;
innerRef: (node: HTMLDivElement) => void;
innerRef: (index: number, node: HTMLDivElement) => void;
ariaId: string;
shouldDisplayDescription: boolean;
}
export function SuggestionComponent(props: Props) {
export const SuggestionComponent = React.memo(function SuggestionComponent(props: Props) {
const { index, innerRef, onClick, onMouseEnter, suggestion } = props;
const setRef = useCallback(
(node: HTMLDivElement) => {
innerRef(index, node);
},
[index, innerRef]
);
const handleClick = useCallback(() => {
onClick(suggestion, index);
}, [index, onClick, suggestion]);
const handleMouseEnter = useCallback(() => {
onMouseEnter(suggestion, index);
}, [index, onMouseEnter, suggestion]);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
@ -50,9 +66,9 @@ export function SuggestionComponent(props: Props) {
active: props.selected,
})}
role="option"
onClick={() => props.onClick(props.suggestion, props.index)}
onMouseEnter={props.onMouseEnter}
ref={props.innerRef}
onMouseEnter={handleMouseEnter}
onClick={handleClick}
ref={setRef}
id={props.ariaId}
aria-selected={props.selected}
data-test-subj={`autocompleteSuggestion-${
@ -72,4 +88,4 @@ export function SuggestionComponent(props: Props) {
</div>
</div>
);
}
});

View file

@ -16,6 +16,8 @@ const noop = () => {
return;
};
const mockContainerDiv = document.createElement('div');
const mockSuggestions: QuerySuggestion[] = [
{
description: 'This is not a helpful suggestion',
@ -43,7 +45,7 @@ describe('SuggestionsComponent', () => {
show={false}
suggestions={mockSuggestions}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
@ -59,7 +61,7 @@ describe('SuggestionsComponent', () => {
show={true}
suggestions={[]}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
@ -67,7 +69,7 @@ describe('SuggestionsComponent', () => {
});
it('Should display given suggestions if the show prop is true', () => {
const component = shallow(
const component = mount(
<SuggestionsComponent
index={0}
onClick={noop}
@ -75,16 +77,16 @@ describe('SuggestionsComponent', () => {
show={true}
suggestions={mockSuggestions}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
expect(component.isEmptyRender()).toBe(false);
expect(component).toMatchSnapshot();
expect(component.find(SuggestionComponent)).toHaveLength(2);
});
it('Passing the index should control which suggestion is selected', () => {
const component = shallow(
const component = mount(
<SuggestionsComponent
index={1}
onClick={noop}
@ -92,11 +94,11 @@ describe('SuggestionsComponent', () => {
show={true}
suggestions={mockSuggestions}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
expect(component).toMatchSnapshot();
expect(component.find(SuggestionComponent).at(1).prop('selected')).toBe(true);
});
it('Should call onClick with the selected suggestion when it is clicked', () => {
@ -109,7 +111,7 @@ describe('SuggestionsComponent', () => {
show={true}
suggestions={mockSuggestions}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
@ -128,12 +130,12 @@ describe('SuggestionsComponent', () => {
show={true}
suggestions={mockSuggestions}
loadMore={noop}
queryBarRect={{ top: 0 } as DOMRect}
inputContainer={mockContainerDiv}
/>
);
component.find(SuggestionComponent).at(1).simulate('mouseenter');
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(1);
expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1);
});
});

View file

@ -7,9 +7,11 @@
*/
import { isEmpty } from 'lodash';
import React, { Component } from 'react';
import React, { PureComponent, ReactNode } from 'react';
import classNames from 'classnames';
import styled from 'styled-components';
import useRafState from 'react-use/lib/useRafState';
import { QuerySuggestion } from '../../autocomplete';
import { SuggestionComponent } from './suggestion_component';
import {
@ -17,86 +19,86 @@ import {
SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET,
SUGGESTIONS_LIST_REQUIRED_WIDTH,
} from './constants';
import { SuggestionOnClick } from './types';
import { SuggestionOnClick, SuggestionOnMouseEnter } from './types';
import { onRaf } from '../utils';
import { shallowEqual } from '../../utils/shallow_equal';
interface SuggestionsComponentProps {
index: number | null;
onClick: SuggestionOnClick;
onMouseEnter: (index: number) => void;
onMouseEnter: SuggestionOnMouseEnter;
show: boolean;
suggestions: QuerySuggestion[];
loadMore: () => void;
queryBarRect?: DOMRect;
size?: SuggestionsListSize;
inputContainer: HTMLElement | null;
}
export type SuggestionsListSize = 's' | 'l';
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default class SuggestionsComponent extends Component<SuggestionsComponentProps> {
export default class SuggestionsComponent extends PureComponent<SuggestionsComponentProps> {
private childNodes: HTMLDivElement[] = [];
private parentNode: HTMLDivElement | null = null;
constructor(props: SuggestionsComponentProps) {
super(props);
this.assignParentNode = this.assignParentNode.bind(this);
this.assignChildNode = this.assignChildNode.bind(this);
}
private assignParentNode(node: HTMLDivElement) {
this.parentNode = node;
}
private assignChildNode(index: number, node: HTMLDivElement) {
this.childNodes[index] = node;
}
public render() {
if (!this.props.queryBarRect || !this.props.show || isEmpty(this.props.suggestions)) {
if (!this.props.inputContainer || !this.props.show || isEmpty(this.props.suggestions)) {
return null;
}
const suggestions = this.props.suggestions.map((suggestion, index) => {
const isDescriptionFittable =
this.props.queryBarRect!.width >= SUGGESTIONS_LIST_REQUIRED_WIDTH;
return (
<SuggestionComponent
innerRef={(node) => (this.childNodes[index] = node)}
selected={index === this.props.index}
index={index}
suggestion={suggestion}
onClick={this.props.onClick}
onMouseEnter={() => this.props.onMouseEnter(index)}
ariaId={'suggestion-' + index}
key={`${suggestion.type} - ${suggestion.text}`}
shouldDisplayDescription={isDescriptionFittable}
/>
);
});
const renderSuggestions = (containerWidth: number) => {
const isDescriptionFittable = containerWidth >= SUGGESTIONS_LIST_REQUIRED_WIDTH;
const suggestions = this.props.suggestions.map((suggestion, index) => {
return (
<SuggestionComponent
innerRef={this.assignChildNode}
selected={index === this.props.index}
index={index}
suggestion={suggestion}
onClick={this.props.onClick}
onMouseEnter={this.props.onMouseEnter}
ariaId={'suggestion-' + index}
key={`${suggestion.type} - ${suggestion.text}`}
shouldDisplayDescription={isDescriptionFittable}
/>
);
});
const documentHeight = document.documentElement.clientHeight || window.innerHeight;
const { queryBarRect } = this.props;
// reflects if the suggestions list has enough space below to be opened down
const isSuggestionsListFittable =
documentHeight - (queryBarRect.top + queryBarRect.height) >
SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE;
const verticalListPosition = isSuggestionsListFittable
? `top: ${window.scrollY + queryBarRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;`
: `bottom: ${documentHeight - (window.scrollY + queryBarRect.top)}px;`;
return suggestions;
};
return (
<StyledSuggestionsListDiv
queryBarRect={queryBarRect}
verticalListPosition={verticalListPosition}
<ResizableSuggestionsListDiv
inputContainer={this.props.inputContainer}
suggestionsSize={this.props.size}
>
<div
className={classNames('kbnTypeahead', { 'kbnTypeahead--small': this.props.size === 's' })}
>
{(containerWidth: number) => (
<div
className={classNames('kbnTypeahead__popover', {
['kbnTypeahead__popover--bottom']: isSuggestionsListFittable,
['kbnTypeahead__popover--top']: !isSuggestionsListFittable,
})}
id="kbnTypeahead__items"
role="listbox"
ref={this.assignParentNode}
onScroll={this.handleScroll}
>
<div
id="kbnTypeahead__items"
role="listbox"
ref={(node) => (this.parentNode = node)}
onScroll={this.handleScroll}
>
{suggestions}
</div>
{renderSuggestions(containerWidth)}
</div>
</div>
</StyledSuggestionsListDiv>
)}
</ResizableSuggestionsListDiv>
);
}
@ -106,7 +108,7 @@ export default class SuggestionsComponent extends Component<SuggestionsComponent
}
}
private scrollIntoView = () => {
private scrollIntoView = onRaf(() => {
if (this.props.index === null) {
return;
}
@ -123,9 +125,9 @@ export default class SuggestionsComponent extends Component<SuggestionsComponent
);
parent.scrollTop = scrollTop;
};
});
private handleScroll = () => {
private handleScroll = onRaf(() => {
if (!this.props.loadMore || !this.parentNode) {
return;
}
@ -141,14 +143,130 @@ export default class SuggestionsComponent extends Component<SuggestionsComponent
if (remaining <= margin) {
this.props.loadMore();
}
};
});
}
const StyledSuggestionsListDiv = styled.div`
${(props: { queryBarRect: DOMRect; verticalListPosition: string }) => `
${(props: { left: number; width: number; verticalListPosition: string }) => `
position: absolute;
z-index: 4001;
left: ${props.queryBarRect.left}px;
width: ${props.queryBarRect.width}px;
left: ${props.left}px;
width: ${props.width}px;
${props.verticalListPosition}`}
`;
const ResizableSuggestionsListDiv: React.FC<{
inputContainer: HTMLElement;
suggestionsSize?: SuggestionsListSize;
}> = React.memo((props) => {
const inputContainer = props.inputContainer;
const children = props.children as (rect: DOMRect) => ReactNode;
const [{ documentHeight }, { pageYOffset }, containerRect] = useDimensions(inputContainer);
if (!containerRect) return null;
// reflects if the suggestions list has enough space below to be opened down
const isSuggestionsListFittable =
documentHeight - (containerRect.top + containerRect.height) >
SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE;
const verticalListPosition = isSuggestionsListFittable
? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;`
: `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`;
return (
<StyledSuggestionsListDiv
left={containerRect.left}
width={containerRect.width}
verticalListPosition={verticalListPosition}
>
<div
className={classNames('kbnTypeahead', {
'kbnTypeahead--small': props.suggestionsSize === 's',
})}
>
<div
className={classNames('kbnTypeahead__popover', {
['kbnTypeahead__popover--bottom']: isSuggestionsListFittable,
['kbnTypeahead__popover--top']: !isSuggestionsListFittable,
})}
>
{children(containerRect)}
</div>
</div>
</StyledSuggestionsListDiv>
);
});
function useDimensions(
container: HTMLElement | null
): [{ documentHeight: number }, { pageYOffset: number; pageXOffset: number }, DOMRect | null] {
const [documentHeight, setDocumentHeight] = useRafState(
() => document.documentElement.clientHeight || window.innerHeight
);
const [pageOffset, setPageOffset] = useRafState<{ pageXOffset: number; pageYOffset: number }>(
() => ({
pageXOffset: window.pageXOffset,
pageYOffset: window.pageYOffset,
})
);
const [containerRect, setContainerRect] = useRafState<DOMRect | null>(() => {
return container?.getBoundingClientRect() ?? null;
});
const updateContainerRect = React.useCallback(() => {
setContainerRect((oldRect: DOMRect | null) => {
const newRect = container?.getBoundingClientRect() ?? null;
const rectsEqual = shallowEqual(oldRect?.toJSON(), newRect?.toJSON());
return rectsEqual ? oldRect : newRect;
});
}, [container, setContainerRect]);
React.useEffect(() => {
const handler = () => {
setDocumentHeight(document.documentElement.clientHeight || window.innerHeight);
};
window.addEventListener('resize', handler, { passive: true });
return () => {
window.removeEventListener('resize', handler);
};
}, [setDocumentHeight]);
React.useEffect(() => {
const handler = () => {
setPageOffset((state) => {
const { pageXOffset, pageYOffset } = window;
return state.pageXOffset !== pageXOffset || state.pageYOffset !== pageYOffset
? {
pageXOffset,
pageYOffset,
}
: state;
});
updateContainerRect();
};
window.addEventListener('scroll', handler, { passive: true, capture: true });
const resizeObserver =
typeof window.ResizeObserver !== 'undefined' &&
new ResizeObserver(() => {
updateContainerRect();
});
if (container && resizeObserver) {
resizeObserver.observe(container);
}
return () => {
window.removeEventListener('scroll', handler, { capture: true });
if (resizeObserver) resizeObserver.disconnect();
};
}, [setPageOffset, container, updateContainerRect]);
return [{ documentHeight }, pageOffset, containerRect];
}

View file

@ -9,3 +9,5 @@
import { QuerySuggestion } from '../../autocomplete';
export type SuggestionOnClick = (suggestion: QuerySuggestion, index: number) => void;
export type SuggestionOnMouseEnter = (suggestion: QuerySuggestion, index: number) => void;

View file

@ -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 { onRaf } from './on_raf';

View file

@ -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.
*/
/**
* Debounce a function till next animation frame
* @param fn
*/
export function onRaf(fn: Function) {
let req: number | null;
return (...args: unknown[]) => {
if (req) window.cancelAnimationFrame(req);
req = window.requestAnimationFrame(() => {
req = null;
fn(...args);
});
};
}

View file

@ -0,0 +1,36 @@
/*
* 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.
*/
/**
* Shallow Equal check adapted from react-redux
* Copy-pasted to avoid importing copy of react-redux into data plugin async chunk
**/
export function shallowEqual(objA: unknown, objB: unknown): boolean {
if (Object.is(objA, objB)) return true;
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
// @ts-ignore
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}
return true;
}

View file

@ -6178,11 +6178,6 @@
dependencies:
"@types/unist" "*"
"@types/memoize-one@^4.1.0":
version "4.1.1"
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369"
integrity sha512-+9djKUUn8hOyktLCfCy4hLaIPgDNovaU36fsnZe9trFHr6ddlbIn2q0SEsnkCkNR+pBWEU440Molz/+Mpyf+gQ==
"@types/micromatch@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7"
@ -19571,6 +19566,11 @@ memfs@^3.1.2:
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
memoize-one@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
memoizee@0.4.X:
version "0.4.14"
resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57"