[Discover][Main] Split single query into 2 queries for faster results (#104818)

Co-authored-by: Tim Roes <tim.roes@elastic.co>
This commit is contained in:
Matthias Wilhelm 2021-08-02 15:07:58 +02:00 committed by GitHub
parent 7e12ea84d5
commit 47f5f81765
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2315 additions and 809 deletions

View file

@ -6,9 +6,11 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import React from 'react';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { renderHook, act } from '@testing-library/react-hooks'; import { renderHook, act } from '@testing-library/react-hooks';
import { render, act as renderAct } from '@testing-library/react';
import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
@ -105,6 +107,30 @@ describe('ThemeService', () => {
act(() => darkMode$.next(false)); act(() => darkMode$.next(false));
expect(result.current).toBe(EUI_CHARTS_THEME_LIGHT.theme); expect(result.current).toBe(EUI_CHARTS_THEME_LIGHT.theme);
}); });
it('should not rerender when emitting the same value', () => {
const darkMode$ = new BehaviorSubject(false);
setupMockUiSettings.get$.mockReturnValue(darkMode$);
const themeService = new ThemeService();
themeService.init(setupMockUiSettings);
const { useChartsTheme } = themeService;
const renderCounter = jest.fn();
const Wrapper = () => {
useChartsTheme();
renderCounter();
return null;
};
render(<Wrapper />);
expect(renderCounter).toHaveBeenCalledTimes(1);
renderAct(() => darkMode$.next(true));
expect(renderCounter).toHaveBeenCalledTimes(2);
renderAct(() => darkMode$.next(true));
renderAct(() => darkMode$.next(true));
renderAct(() => darkMode$.next(true));
expect(renderCounter).toHaveBeenCalledTimes(2);
});
}); });
describe('useBaseChartTheme', () => { describe('useBaseChartTheme', () => {
@ -123,5 +149,29 @@ describe('ThemeService', () => {
act(() => darkMode$.next(false)); act(() => darkMode$.next(false));
expect(result.current).toBe(LIGHT_THEME); expect(result.current).toBe(LIGHT_THEME);
}); });
it('should not rerender when emitting the same value', () => {
const darkMode$ = new BehaviorSubject(false);
setupMockUiSettings.get$.mockReturnValue(darkMode$);
const themeService = new ThemeService();
themeService.init(setupMockUiSettings);
const { useChartsBaseTheme } = themeService;
const renderCounter = jest.fn();
const Wrapper = () => {
useChartsBaseTheme();
renderCounter();
return null;
};
render(<Wrapper />);
expect(renderCounter).toHaveBeenCalledTimes(1);
renderAct(() => darkMode$.next(true));
expect(renderCounter).toHaveBeenCalledTimes(2);
renderAct(() => darkMode$.next(true));
renderAct(() => darkMode$.next(true));
renderAct(() => darkMode$.next(true));
expect(renderCounter).toHaveBeenCalledTimes(2);
});
}); });
}); });

View file

@ -6,7 +6,7 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Observable, BehaviorSubject } from 'rxjs'; import { Observable, BehaviorSubject } from 'rxjs';
import { CoreSetup } from 'kibana/public'; import { CoreSetup } from 'kibana/public';
@ -54,11 +54,18 @@ export class ThemeService {
/** A React hook for consuming the charts theme */ /** A React hook for consuming the charts theme */
public useChartsTheme = (): PartialTheme => { public useChartsTheme = (): PartialTheme => {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const [value, update] = useState(this.chartsDefaultTheme); const [value, update] = useState(this._chartsTheme$.getValue());
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = useRef(value);
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
const s = this.chartsTheme$.subscribe(update); const s = this.chartsTheme$.subscribe((val) => {
if (val !== ref.current) {
ref.current = val;
update(val);
}
});
return () => s.unsubscribe(); return () => s.unsubscribe();
}, []); }, []);
@ -68,11 +75,18 @@ export class ThemeService {
/** A React hook for consuming the charts theme */ /** A React hook for consuming the charts theme */
public useChartsBaseTheme = (): Theme => { public useChartsBaseTheme = (): Theme => {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const [value, update] = useState(this.chartsDefaultBaseTheme); const [value, update] = useState(this._chartsBaseTheme$.getValue());
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = useRef(value);
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
const s = this.chartsBaseTheme$.subscribe(update); const s = this.chartsBaseTheme$.subscribe((val) => {
if (val !== ref.current) {
ref.current = val;
update(val);
}
});
return () => s.unsubscribe(); return () => s.unsubscribe();
}, []); }, []);

View file

@ -42,13 +42,17 @@ export const searchSourceCommonMock: jest.Mocked<ISearchStartSearchSource> = {
createEmpty: jest.fn().mockReturnValue(searchSourceInstanceMock), createEmpty: jest.fn().mockReturnValue(searchSourceInstanceMock),
}; };
export const createSearchSourceMock = (fields?: SearchSourceFields) => export const createSearchSourceMock = (fields?: SearchSourceFields, response?: any) =>
new SearchSource(fields, { new SearchSource(fields, {
getConfig: uiSettingsServiceMock.createStartContract().get, getConfig: uiSettingsServiceMock.createStartContract().get,
search: jest search: jest.fn().mockReturnValue(
.fn() of(
.mockReturnValue( response ?? {
of({ rawResponse: { hits: { hits: [], total: 0 } }, isPartial: false, isRunning: false }) rawResponse: { hits: { hits: [], total: 0 } },
), isPartial: false,
isRunning: false,
}
)
),
onResponse: jest.fn().mockImplementation((req, res) => res), onResponse: jest.fn().mockImplementation((req, res) => res),
}); });

View file

@ -71,6 +71,7 @@ const indexPattern = ({
getSourceFiltering: () => ({}), getSourceFiltering: () => ({}),
getFieldByName: (name: string) => fields.getByName(name), getFieldByName: (name: string) => fields.getByName(name),
timeFieldName: 'timestamp', timeFieldName: 'timestamp',
getFormatterForField: () => ({ convert: () => 'formatted' }),
} as unknown) as IndexPattern; } as unknown) as IndexPattern;
indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields);

View file

@ -9,6 +9,7 @@
import { SavedSearch } from '../saved_searches'; import { SavedSearch } from '../saved_searches';
import { createSearchSourceMock } from '../../../data/public/mocks'; import { createSearchSourceMock } from '../../../data/public/mocks';
import { indexPatternMock } from './index_pattern'; import { indexPatternMock } from './index_pattern';
import { indexPatternWithTimefieldMock } from './index_pattern_with_timefield';
export const savedSearchMock = ({ export const savedSearchMock = ({
id: 'the-saved-search-id', id: 'the-saved-search-id',
@ -31,3 +32,25 @@ export const savedSearchMock = ({
error: undefined, error: undefined,
searchSource: createSearchSourceMock({ index: indexPatternMock }), searchSource: createSearchSourceMock({ index: indexPatternMock }),
} as unknown) as SavedSearch; } as unknown) as SavedSearch;
export const savedSearchMockWithTimeField = ({
id: 'the-saved-search-id-with-timefield',
type: 'search',
attributes: {
title: 'the-saved-search-title',
kibanaSavedObjectMeta: {
searchSourceJSON:
'{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}',
},
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: 'the-index-pattern-id',
},
],
migrationVersion: { search: '7.5.0' },
error: undefined,
searchSource: createSearchSourceMock({ index: indexPatternWithTimefieldMock }),
} as unknown) as SavedSearch;

View file

@ -5,10 +5,15 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { DiscoverServices } from '../build_services'; import { DiscoverServices } from '../build_services';
import { dataPluginMock } from '../../../data/public/mocks'; import { dataPluginMock } from '../../../data/public/mocks';
import { chromeServiceMock, coreMock, docLinksServiceMock } from '../../../../core/public/mocks'; import { chromeServiceMock, coreMock, docLinksServiceMock } from '../../../../core/public/mocks';
import { DEFAULT_COLUMNS_SETTING } from '../../common'; import {
DEFAULT_COLUMNS_SETTING,
SAMPLE_SIZE_SETTING,
SORT_DEFAULT_ORDER_SETTING,
} from '../../common';
import { savedSearchMock } from './saved_search'; import { savedSearchMock } from './saved_search';
import { UI_SETTINGS } from '../../../data/common'; import { UI_SETTINGS } from '../../../data/common';
import { TopNavMenu } from '../../../navigation/public'; import { TopNavMenu } from '../../../navigation/public';
@ -44,8 +49,15 @@ export const discoverServiceMock = ({
return []; return [];
} else if (key === UI_SETTINGS.META_FIELDS) { } else if (key === UI_SETTINGS.META_FIELDS) {
return []; return [];
} else if (key === SAMPLE_SIZE_SETTING) {
return 250;
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
return 'desc';
} }
}, },
isDefault: (key: string) => {
return true;
},
}, },
indexPatternFieldEditor: { indexPatternFieldEditor: {
openEditor: jest.fn(), openEditor: jest.fn(),
@ -60,4 +72,8 @@ export const discoverServiceMock = ({
metadata: { metadata: {
branch: 'test', branch: 'test',
}, },
theme: {
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
},
} as unknown) as DiscoverServices; } as unknown) as DiscoverServices;

View file

@ -0,0 +1,122 @@
/*
* 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 React from 'react';
import { Subject, BehaviorSubject } from 'rxjs';
import { mountWithIntl } from '@kbn/test/jest';
import { setHeaderActionMenuMounter } from '../../../../../kibana_services';
import { esHits } from '../../../../../__mocks__/es_hits';
import { savedSearchMock } from '../../../../../__mocks__/saved_search';
import { createSearchSourceMock } from '../../../../../../../data/common/search/search_source/mocks';
import { GetStateReturn } from '../../services/discover_state';
import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search';
import { discoverServiceMock } from '../../../../../__mocks__/services';
import { FetchStatus } from '../../../../types';
import { Chart } from './point_series';
import { DiscoverChart } from './discover_chart';
setHeaderActionMenuMounter(jest.fn());
function getProps(timefield?: string) {
const searchSourceMock = createSearchSourceMock({});
const services = discoverServiceMock;
services.data.query.timefilter.timefilter.getTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
};
const totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: Number(esHits.length),
}) as DataTotalHits$;
const chartData = ({
xAxisOrderedValues: [
1623880800000,
1623967200000,
1624053600000,
1624140000000,
1624226400000,
1624312800000,
1624399200000,
1624485600000,
1624572000000,
1624658400000,
1624744800000,
1624831200000,
1624917600000,
1625004000000,
1625090400000,
],
xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
xAxisLabel: 'order_date per day',
yAxisFormat: { id: 'number' },
ordered: {
date: true,
interval: {
asMilliseconds: jest.fn(),
},
intervalESUnit: 'd',
intervalESValue: 1,
min: '2021-03-18T08:28:56.411Z',
max: '2021-07-01T07:28:56.411Z',
},
yAxisLabel: 'Count',
values: [
{ x: 1623880800000, y: 134 },
{ x: 1623967200000, y: 152 },
{ x: 1624053600000, y: 141 },
{ x: 1624140000000, y: 138 },
{ x: 1624226400000, y: 142 },
{ x: 1624312800000, y: 157 },
{ x: 1624399200000, y: 149 },
{ x: 1624485600000, y: 146 },
{ x: 1624572000000, y: 170 },
{ x: 1624658400000, y: 137 },
{ x: 1624744800000, y: 150 },
{ x: 1624831200000, y: 144 },
{ x: 1624917600000, y: 147 },
{ x: 1625004000000, y: 137 },
{ x: 1625090400000, y: 66 },
],
} as unknown) as Chart;
const charts$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
chartData,
bucketInterval: {
scaled: true,
description: 'test',
scale: 2,
},
}) as DataCharts$;
return {
isLegacy: false,
resetQuery: jest.fn(),
savedSearch: savedSearchMock,
savedSearchDataChart$: charts$,
savedSearchDataTotalHits$: totalHits$,
savedSearchRefetch$: new Subject(),
searchSource: searchSourceMock,
services,
state: { columns: [] },
stateContainer: {} as GetStateReturn,
timefield,
};
}
describe('Discover chart', () => {
test('render without timefield', () => {
const component = mountWithIntl(<DiscoverChart {...getProps()} />);
expect(component.find('[data-test-subj="discoverChartToggle"]').exists()).toBeFalsy();
});
test('render with filefield', () => {
const component = mountWithIntl(<DiscoverChart {...getProps('timefield')} />);
expect(component.find('[data-test-subj="discoverChartToggle"]').exists()).toBeTruthy();
});
});

View file

@ -5,48 +5,43 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef, memo } from 'react';
import moment from 'moment'; import moment from 'moment';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { IUiSettingsClient } from 'kibana/public';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { HitsCounter } from '../hits_counter'; import { HitsCounter } from '../hits_counter';
import { DataPublicPluginStart, IndexPattern, search } from '../../../../../../../data/public'; import { search } from '../../../../../../../data/public';
import { TimechartHeader } from '../timechart_header'; import { TimechartHeader } from '../timechart_header';
import { SavedSearch } from '../../../../../saved_searches'; import { SavedSearch } from '../../../../../saved_searches';
import { AppState, GetStateReturn } from '../../services/discover_state'; import { AppState, GetStateReturn } from '../../services/discover_state';
import { TimechartBucketInterval } from '../timechart_header/timechart_header';
import { Chart as IChart } from './point_series';
import { DiscoverHistogram } from './histogram'; import { DiscoverHistogram } from './histogram';
import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search';
import { DiscoverServices } from '../../../../../build_services';
const TimechartHeaderMemoized = React.memo(TimechartHeader); const TimechartHeaderMemoized = memo(TimechartHeader);
const DiscoverHistogramMemoized = React.memo(DiscoverHistogram); const DiscoverHistogramMemoized = memo(DiscoverHistogram);
export function DiscoverChart({ export function DiscoverChart({
config,
data,
bucketInterval,
chartData,
hits,
isLegacy, isLegacy,
resetQuery, resetQuery,
savedSearch, savedSearch,
savedSearchDataChart$,
savedSearchDataTotalHits$,
services,
state, state,
stateContainer, stateContainer,
timefield, timefield,
}: { }: {
config: IUiSettingsClient;
data: DataPublicPluginStart;
bucketInterval?: TimechartBucketInterval;
chartData?: IChart;
hits?: number;
indexPattern: IndexPattern;
isLegacy: boolean; isLegacy: boolean;
resetQuery: () => void; resetQuery: () => void;
savedSearch: SavedSearch; savedSearch: SavedSearch;
savedSearchDataChart$: DataCharts$;
savedSearchDataTotalHits$: DataTotalHits$;
services: DiscoverServices;
state: AppState; state: AppState;
stateContainer: GetStateReturn; stateContainer: GetStateReturn;
timefield?: string; timefield?: string;
}) { }) {
const { data, uiSettings: config } = services;
const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({ const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({
element: null, element: null,
moveFocus: false, moveFocus: false,
@ -93,7 +88,7 @@ export function DiscoverChart({
className="dscResuntCount__title eui-textTruncate eui-textNoWrap" className="dscResuntCount__title eui-textTruncate eui-textNoWrap"
> >
<HitsCounter <HitsCounter
hits={hits} savedSearchData$={savedSearchDataTotalHits$}
showResetButton={!!(savedSearch && savedSearch.id)} showResetButton={!!(savedSearch && savedSearch.id)}
onResetQuery={resetQuery} onResetQuery={resetQuery}
/> />
@ -106,7 +101,7 @@ export function DiscoverChart({
options={search.aggs.intervalOptions} options={search.aggs.intervalOptions}
onChangeInterval={onChangeInterval} onChangeInterval={onChangeInterval}
stateInterval={state.interval || ''} stateInterval={state.interval || ''}
bucketInterval={bucketInterval} savedSearchData$={savedSearchDataChart$}
/> />
</EuiFlexItem> </EuiFlexItem>
)} )}
@ -130,7 +125,7 @@ export function DiscoverChart({
)} )}
</EuiFlexGroup> </EuiFlexGroup>
</EuiFlexItem> </EuiFlexItem>
{!state.hideChart && chartData && ( {timefield && !state.hideChart && (
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<section <section
ref={(element) => (chartRef.current.element = element)} ref={(element) => (chartRef.current.element = element)}
@ -145,8 +140,9 @@ export function DiscoverChart({
data-test-subj="discoverChart" data-test-subj="discoverChart"
> >
<DiscoverHistogramMemoized <DiscoverHistogramMemoized
chartData={chartData} savedSearchData$={savedSearchDataChart$}
timefilterUpdateHandler={timefilterUpdateHandler} timefilterUpdateHandler={timefilterUpdateHandler}
services={services}
/> />
</div> </div>
</section> </section>

View file

@ -0,0 +1,9 @@
.dscChart__loading {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 0 100%;
text-align: center;
height: 100%;
width: 100%;
}

View file

@ -5,45 +5,41 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import './histogram.scss';
import moment, { unitOfTime } from 'moment-timezone'; import moment, { unitOfTime } from 'moment-timezone';
import React, { Component } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import { EuiLoadingChart, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { import {
Axis, Axis,
BrushEndListener,
Chart, Chart,
ElementClickListener,
HistogramBarSeries, HistogramBarSeries,
Position, Position,
ScaleType, ScaleType,
Settings, Settings,
TooltipType, TooltipType,
ElementClickListener,
XYChartElementEvent, XYChartElementEvent,
BrushEndListener,
Theme,
} from '@elastic/charts'; } from '@elastic/charts';
import { IUiSettingsClient } from 'kibana/public'; import { IUiSettingsClient } from 'kibana/public';
import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme';
import { Subscription, combineLatest } from 'rxjs';
import { getServices } from '../../../../../kibana_services';
import { Chart as IChart } from './point_series';
import { import {
CurrentTime, CurrentTime,
Endzones, Endzones,
getAdjustedInterval, getAdjustedInterval,
renderEndzoneTooltip, renderEndzoneTooltip,
} from '../../../../../../../charts/public'; } from '../../../../../../../charts/public';
import { DataCharts$, DataChartsMessage } from '../../services/use_saved_search';
import { FetchStatus } from '../../../../types';
import { DiscoverServices } from '../../../../../build_services';
import { useDataState } from '../../utils/use_data_state';
export interface DiscoverHistogramProps { export interface DiscoverHistogramProps {
chartData: IChart; savedSearchData$: DataCharts$;
timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; timefilterUpdateHandler: (ranges: { from: number; to: number }) => void;
} services: DiscoverServices;
interface DiscoverHistogramState {
chartsTheme: EuiChartThemeType['theme'];
chartsBaseTheme: Theme;
} }
function getTimezone(uiSettings: IUiSettingsClient) { function getTimezone(uiSettings: IUiSettingsClient) {
@ -56,154 +52,149 @@ function getTimezone(uiSettings: IUiSettingsClient) {
} }
} }
export class DiscoverHistogram extends Component<DiscoverHistogramProps, DiscoverHistogramState> { export function DiscoverHistogram({
public static propTypes = { savedSearchData$,
chartData: PropTypes.object, timefilterUpdateHandler,
timefilterUpdateHandler: PropTypes.func, services,
}; }: DiscoverHistogramProps) {
const chartTheme = services.theme.useChartsTheme();
const chartBaseTheme = services.theme.useChartsBaseTheme();
private subscription?: Subscription; const dataState: DataChartsMessage = useDataState(savedSearchData$);
public state = {
chartsTheme: getServices().theme.chartsDefaultTheme,
chartsBaseTheme: getServices().theme.chartsDefaultBaseTheme,
};
componentDidMount() { const uiSettings = services.uiSettings;
this.subscription = combineLatest([ const timeZone = getTimezone(uiSettings);
getServices().theme.chartsTheme$, const { chartData, fetchStatus } = dataState;
getServices().theme.chartsBaseTheme$,
]).subscribe(([chartsTheme, chartsBaseTheme]) => const onBrushEnd: BrushEndListener = useCallback(
this.setState({ chartsTheme, chartsBaseTheme }) ({ x }) => {
if (!x) {
return;
}
const [from, to] = x;
timefilterUpdateHandler({ from, to });
},
[timefilterUpdateHandler]
);
const onElementClick = useCallback(
(xInterval: number): ElementClickListener => ([elementData]) => {
const startRange = (elementData as XYChartElementEvent)[0].x;
const range = {
from: startRange,
to: startRange + xInterval,
};
timefilterUpdateHandler(range);
},
[timefilterUpdateHandler]
);
if (!chartData && fetchStatus === FetchStatus.LOADING) {
return (
<div className="dscChart__loading">
<EuiText size="xs" color="subdued">
<EuiLoadingChart mono size="l" />
<EuiSpacer size="s" />
<FormattedMessage id="discover.loadingChartResults" defaultMessage="Loading chart" />
</EuiText>
</div>
); );
} }
componentWillUnmount() { if (!chartData) {
if (this.subscription) { return null;
this.subscription.unsubscribe();
}
} }
public onBrushEnd: BrushEndListener = ({ x }) => { const formatXValue = (val: string) => {
if (!x) { const xAxisFormat = chartData.xAxisFormat.params!.pattern;
return;
}
const [from, to] = x;
this.props.timefilterUpdateHandler({ from, to });
};
public onElementClick = (xInterval: number): ElementClickListener => ([elementData]) => {
const startRange = (elementData as XYChartElementEvent)[0].x;
const range = {
from: startRange,
to: startRange + xInterval,
};
this.props.timefilterUpdateHandler(range);
};
public formatXValue = (val: string) => {
const xAxisFormat = this.props.chartData.xAxisFormat.params!.pattern;
return moment(val).format(xAxisFormat); return moment(val).format(xAxisFormat);
}; };
public render() { const data = chartData.values;
const uiSettings = getServices().uiSettings; const isDarkMode = uiSettings.get('theme:darkMode');
const timeZone = getTimezone(uiSettings);
const { chartData } = this.props;
const { chartsTheme, chartsBaseTheme } = this.state;
if (!chartData) { /*
return null; * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval].
} * see https://github.com/elastic/kibana/issues/27410
* TODO: Once the Discover query has been update, we should change the below to use the new field
*/
const { intervalESValue, intervalESUnit, interval } = chartData.ordered;
const xInterval = interval.asMilliseconds();
const data = chartData.values; const xValues = chartData.xAxisOrderedValues;
const isDarkMode = uiSettings.get('theme:darkMode'); const lastXValue = xValues[xValues.length - 1];
/* const domain = chartData.ordered;
* Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. const domainStart = domain.min.valueOf();
* see https://github.com/elastic/kibana/issues/27410 const domainEnd = domain.max.valueOf();
* TODO: Once the Discover query has been update, we should change the below to use the new field
*/
const { intervalESValue, intervalESUnit, interval } = chartData.ordered;
const xInterval = interval.asMilliseconds();
const xValues = chartData.xAxisOrderedValues; const domainMin = Math.min(data[0]?.x, domainStart);
const lastXValue = xValues[xValues.length - 1]; const domainMax = Math.max(domainEnd - xInterval, lastXValue);
const domain = chartData.ordered; const xDomain = {
const domainStart = domain.min.valueOf(); min: domainMin,
const domainEnd = domain.max.valueOf(); max: domainMax,
minInterval: getAdjustedInterval(
xValues,
intervalESValue,
intervalESUnit as unitOfTime.Base,
timeZone
),
};
const tooltipProps = {
headerFormatter: renderEndzoneTooltip(xInterval, domainStart, domainEnd, formatXValue),
type: TooltipType.VerticalCursor,
};
const domainMin = Math.min(data[0]?.x, domainStart); const xAxisFormatter = services.data.fieldFormats.deserialize(chartData.yAxisFormat);
const domainMax = Math.max(domainEnd - xInterval, lastXValue);
const xDomain = { return (
min: domainMin, <Chart size="100%">
max: domainMax, <Settings
minInterval: getAdjustedInterval( xDomain={xDomain}
xValues, onBrushEnd={onBrushEnd}
intervalESValue, onElementClick={onElementClick(xInterval)}
intervalESUnit as unitOfTime.Base, tooltip={tooltipProps}
timeZone theme={chartTheme}
), baseTheme={chartBaseTheme}
}; />
const tooltipProps = { <Axis
headerFormatter: renderEndzoneTooltip(xInterval, domainStart, domainEnd, this.formatXValue), id="discover-histogram-left-axis"
type: TooltipType.VerticalCursor, position={Position.Left}
}; ticks={5}
title={chartData.yAxisLabel}
const xAxisFormatter = getServices().data.fieldFormats.deserialize( integersOnly
this.props.chartData.yAxisFormat tickFormat={(value) => xAxisFormatter.convert(value)}
); />
<Axis
return ( id="discover-histogram-bottom-axis"
<Chart size="100%"> position={Position.Bottom}
<Settings title={chartData.xAxisLabel}
xDomain={xDomain} tickFormat={formatXValue}
onBrushEnd={this.onBrushEnd} ticks={10}
onElementClick={this.onElementClick(xInterval)} />
tooltip={tooltipProps} <CurrentTime isDarkMode={isDarkMode} domainEnd={domainEnd} />
theme={chartsTheme} <Endzones
baseTheme={chartsBaseTheme} isDarkMode={isDarkMode}
/> domainStart={domainStart}
<Axis domainEnd={domainEnd}
id="discover-histogram-left-axis" interval={xDomain.minInterval}
position={Position.Left} domainMin={xDomain.min}
ticks={5} domainMax={xDomain.max}
title={chartData.yAxisLabel} />
integersOnly <HistogramBarSeries
tickFormat={(value) => xAxisFormatter.convert(value)} id="discover-histogram"
/> minBarHeight={2}
<Axis xScaleType={ScaleType.Time}
id="discover-histogram-bottom-axis" yScaleType={ScaleType.Linear}
position={Position.Bottom} xAccessor="x"
title={chartData.xAxisLabel} yAccessors={['y']}
tickFormat={this.formatXValue} data={data}
ticks={10} timeZone={timeZone}
/> name={chartData.yAxisLabel}
<CurrentTime isDarkMode={isDarkMode} domainEnd={domainEnd} /> />
<Endzones </Chart>
isDarkMode={isDarkMode} );
domainStart={domainStart}
domainEnd={domainEnd}
interval={xDomain.minInterval}
domainMin={xDomain.min}
domainMax={xDomain.max}
/>
<HistogramBarSeries
id="discover-histogram"
minBarHeight={2}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
data={data}
timeZone={timeZone}
name={chartData.yAxisLabel}
/>
</Chart>
);
}
} }

View file

@ -0,0 +1,120 @@
/*
* 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 { buildPointSeriesData } from './point_series';
import moment from 'moment';
import { Unit } from '@elastic/datemath';
describe('buildPointSeriesData', () => {
test('with valid data', () => {
const table = {
type: 'datatable',
columns: [
{
id: 'col-0-2',
name: 'order_date per 30 days',
meta: {
type: 'date',
field: 'order_date',
index: 'kibana_sample_data_ecommerce',
params: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
source: 'esaggs',
sourceParams: {
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
id: '2',
enabled: true,
type: 'date_histogram',
params: {
field: 'order_date',
timeRange: { from: 'now-15y', to: 'now' },
useNormalizedEsInterval: true,
scaleMetricValues: false,
interval: 'auto',
used_interval: '30d',
drop_partials: false,
min_doc_count: 1,
extended_bounds: {},
},
schema: 'segment',
},
},
},
{
id: 'col-1-1',
name: 'Count',
meta: {
type: 'number',
index: 'kibana_sample_data_ecommerce',
params: { id: 'number' },
source: 'esaggs',
sourceParams: {
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
id: '1',
enabled: true,
type: 'count',
params: {},
schema: 'metric',
},
},
},
],
rows: [{ 'col-0-2': 1625176800000, 'col-1-1': 2139 }],
};
const dimensions = {
x: {
accessor: 0,
label: 'order_date per 30 days',
format: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
params: {
date: true,
interval: moment.duration(30, 'd'),
intervalESValue: 30,
intervalESUnit: 'd' as Unit,
format: 'YYYY-MM-DD',
bounds: {
min: moment('2006-07-29T11:08:13.078Z'),
max: moment('2021-07-29T11:08:13.078Z'),
},
},
},
y: { accessor: 1, format: { id: 'number' }, label: 'Count' },
} as const;
expect(buildPointSeriesData(table, dimensions)).toMatchInlineSnapshot(`
Object {
"ordered": Object {
"date": true,
"interval": "P30D",
"intervalESUnit": "d",
"intervalESValue": 30,
"max": "2021-07-29T11:08:13.078Z",
"min": "2006-07-29T11:08:13.078Z",
},
"values": Array [
Object {
"x": 1625176800000,
"y": 2139,
},
],
"xAxisFormat": Object {
"id": "date",
"params": Object {
"pattern": "YYYY-MM-DD",
},
},
"xAxisLabel": "order_date per 30 days",
"xAxisOrderedValues": Array [
1625176800000,
],
"yAxisFormat": Object {
"id": "number",
},
"yAxisLabel": "Count",
}
`);
});
});

View file

@ -11,6 +11,9 @@ import { mountWithIntl } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme'; import { ReactWrapper } from 'enzyme';
import { HitsCounter, HitsCounterProps } from './hits_counter'; import { HitsCounter, HitsCounterProps } from './hits_counter';
import { findTestSubject } from '@elastic/eui/lib/test'; import { findTestSubject } from '@elastic/eui/lib/test';
import { BehaviorSubject } from 'rxjs';
import { FetchStatus } from '../../../../types';
import { DataTotalHits$ } from '../../services/use_saved_search';
describe('hits counter', function () { describe('hits counter', function () {
let props: HitsCounterProps; let props: HitsCounterProps;
@ -20,7 +23,10 @@ describe('hits counter', function () {
props = { props = {
onResetQuery: jest.fn(), onResetQuery: jest.fn(),
showResetButton: true, showResetButton: true,
hits: 2, savedSearchData$: new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 2,
}) as DataTotalHits$,
}; };
}); });
@ -30,9 +36,7 @@ describe('hits counter', function () {
}); });
it('HitsCounter not renders a button when the showResetButton property is false', () => { it('HitsCounter not renders a button when the showResetButton property is false', () => {
component = mountWithIntl( component = mountWithIntl(<HitsCounter {...props} showResetButton={false} />);
<HitsCounter hits={2} showResetButton={false} onResetQuery={jest.fn()} />
);
expect(findTestSubject(component, 'resetSavedSearch').length).toBe(0); expect(findTestSubject(component, 'resetSavedSearch').length).toBe(0);
}); });
@ -43,8 +47,17 @@ describe('hits counter', function () {
}); });
it('expect to render 1,899 hits if 1899 hits given', function () { it('expect to render 1,899 hits if 1899 hits given', function () {
const data$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 1899,
}) as DataTotalHits$;
component = mountWithIntl( component = mountWithIntl(
<HitsCounter hits={1899} showResetButton={false} onResetQuery={jest.fn()} /> <HitsCounter
{...props}
savedSearchData$={data$}
showResetButton={false}
onResetQuery={jest.fn()}
/>
); );
const hits = findTestSubject(component, 'discoverQueryHits'); const hits = findTestSubject(component, 'discoverQueryHits');
expect(hits.text()).toBe('1,899'); expect(hits.text()).toBe('1,899');

View file

@ -7,18 +7,21 @@
*/ */
import './hits_counter.scss'; import './hits_counter.scss';
import React from 'react'; import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import {
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiLoadingSpinner,
} from '@elastic/eui';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { formatNumWithCommas } from '../../../../helpers'; import { DataTotalHits$, DataTotalHitsMsg } from '../../services/use_saved_search';
import { FetchStatus } from '../../../../types';
import { useDataState } from '../../utils/use_data_state';
export interface HitsCounterProps { export interface HitsCounterProps {
/**
* the number of query hits
*/
hits?: number;
/** /**
* displays the reset button * displays the reset button
*/ */
@ -27,52 +30,81 @@ export interface HitsCounterProps {
* resets the query * resets the query
*/ */
onResetQuery: () => void; onResetQuery: () => void;
/**
* saved search data observable
*/
savedSearchData$: DataTotalHits$;
} }
export function HitsCounter({ hits, showResetButton, onResetQuery }: HitsCounterProps) { export function HitsCounter({ showResetButton, onResetQuery, savedSearchData$ }: HitsCounterProps) {
if (typeof hits === 'undefined') { const data: DataTotalHitsMsg = useDataState(savedSearchData$);
const hits = data.result || 0;
if (!hits && data.fetchStatus === FetchStatus.LOADING) {
return null; return null;
} }
const formattedHits = (
<strong
data-test-subj={
data.fetchStatus === FetchStatus.PARTIAL ? 'discoverQueryHitsPartial' : 'discoverQueryHits'
}
>
<FormattedNumber value={hits} />
</strong>
);
return ( return (
<I18nProvider> <EuiFlexGroup
<EuiFlexGroup className="dscHitsCounter"
className="dscHitsCounter" gutterSize="s"
gutterSize="s" responsive={false}
responsive={false} justifyContent="center"
justifyContent="center" alignItems="center"
alignItems="center" >
> <EuiFlexItem grow={false} aria-live="polite">
<EuiFlexItem grow={false}> <EuiText>
<EuiText> {data.fetchStatus === FetchStatus.PARTIAL && (
<strong data-test-subj="discoverQueryHits">{formatNumWithCommas(hits)}</strong>{' '} <FormattedMessage
id="discover.partialHits"
defaultMessage="≥{formattedHits} {hits, plural, one {hit} other {hits}}"
values={{ hits, formattedHits }}
/>
)}
{data.fetchStatus !== FetchStatus.PARTIAL && (
<FormattedMessage <FormattedMessage
id="discover.hitsPluralTitle" id="discover.hitsPluralTitle"
defaultMessage="{hits, plural, one {hit} other {hits}}" defaultMessage="{formattedHits} {hits, plural, one {hit} other {hits}}"
values={{ values={{ hits, formattedHits }}
hits,
}}
/> />
</EuiText> )}
</EuiText>
</EuiFlexItem>
{data.fetchStatus === FetchStatus.PARTIAL && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner
size="m"
aria-label={i18n.translate('discover.hitCountSpinnerAriaLabel', {
defaultMessage: 'Final hit count still loading',
})}
/>
</EuiFlexItem> </EuiFlexItem>
{showResetButton && ( )}
<EuiFlexItem grow={false}> {showResetButton && (
<EuiButtonEmpty <EuiFlexItem grow={false}>
iconType="refresh" <EuiButtonEmpty
data-test-subj="resetSavedSearch" iconType="refresh"
onClick={onResetQuery} data-test-subj="resetSavedSearch"
size="s" onClick={onResetQuery}
aria-label={i18n.translate('discover.reloadSavedSearchButton', { size="s"
defaultMessage: 'Reset search', aria-label={i18n.translate('discover.reloadSavedSearchButton', {
})} defaultMessage: 'Reset search',
> })}
<FormattedMessage >
id="discover.reloadSavedSearchButton" <FormattedMessage id="discover.reloadSavedSearchButton" defaultMessage="Reset search" />
defaultMessage="Reset search" </EuiButtonEmpty>
/> </EuiFlexItem>
</EuiButtonEmpty> )}
</EuiFlexItem> </EuiFlexGroup>
)}
</EuiFlexGroup>
</I18nProvider>
); );
} }

View file

@ -0,0 +1,74 @@
/*
* 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 React from 'react';
import { BehaviorSubject } from 'rxjs';
import { mountWithIntl } from '@kbn/test/jest';
import { setHeaderActionMenuMounter } from '../../../../../kibana_services';
import { esHits } from '../../../../../__mocks__/es_hits';
import { savedSearchMock } from '../../../../../__mocks__/saved_search';
import { GetStateReturn } from '../../services/discover_state';
import { DataDocuments$ } from '../../services/use_saved_search';
import { discoverServiceMock } from '../../../../../__mocks__/services';
import { FetchStatus } from '../../../../types';
import { DiscoverDocuments } from './discover_documents';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { indexPatternMock } from '../../../../../__mocks__/index_pattern';
setHeaderActionMenuMounter(jest.fn());
function getProps(fetchStatus: FetchStatus, hits: ElasticSearchHit[]) {
const services = discoverServiceMock;
services.data.query.timefilter.timefilter.getTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
};
const documents$ = new BehaviorSubject({
fetchStatus,
result: hits,
}) as DataDocuments$;
return {
expandedDoc: undefined,
indexPattern: indexPatternMock,
isMobile: jest.fn(() => false),
onAddFilter: jest.fn(),
savedSearch: savedSearchMock,
documents$,
searchSource: documents$,
services,
setExpandedDoc: jest.fn(),
state: { columns: [] },
stateContainer: {} as GetStateReturn,
navigateTo: jest.fn(),
};
}
describe('Discover documents layout', () => {
test('render loading when loading and no documents', () => {
const component = mountWithIntl(<DiscoverDocuments {...getProps(FetchStatus.LOADING, [])} />);
expect(component.find('.dscDocuments__loading').exists()).toBeTruthy();
expect(component.find('.dscTable').exists()).toBeFalsy();
});
test('render complete when loading but documents were already fetched', () => {
const component = mountWithIntl(
<DiscoverDocuments {...getProps(FetchStatus.LOADING, esHits as ElasticSearchHit[])} />
);
expect(component.find('.dscDocuments__loading').exists()).toBeFalsy();
expect(component.find('.dscTable').exists()).toBeTruthy();
});
test('render complete', () => {
const component = mountWithIntl(
<DiscoverDocuments {...getProps(FetchStatus.COMPLETE, esHits as ElasticSearchHit[])} />
);
expect(component.find('.dscDocuments__loading').exists()).toBeFalsy();
expect(component.find('.dscTable').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,194 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useRef, useMemo, useCallback, memo } from 'react';
import { EuiFlexItem, EuiSpacer, EuiText, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocTableLegacy } from '../../../../angular/doc_table/create_doc_table_react';
import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort';
import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { DiscoverGrid } from '../../../../components/discover_grid/discover_grid';
import { FetchStatus } from '../../../../types';
import {
DOC_HIDE_TIME_COLUMN_SETTING,
DOC_TABLE_LEGACY,
SAMPLE_SIZE_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
} from '../../../../../../common';
import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns';
import { IndexPattern } from '../../../../../../../data/common';
import { SavedSearch } from '../../../../../saved_searches';
import { DataDocumentsMsg, DataDocuments$ } from '../../services/use_saved_search';
import { DiscoverServices } from '../../../../../build_services';
import { AppState, GetStateReturn } from '../../services/discover_state';
import { useDataState } from '../../utils/use_data_state';
const DocTableLegacyMemoized = React.memo(DocTableLegacy);
const DataGridMemoized = React.memo(DiscoverGrid);
function DiscoverDocumentsComponent({
documents$,
expandedDoc,
indexPattern,
isMobile,
onAddFilter,
savedSearch,
services,
setExpandedDoc,
state,
stateContainer,
}: {
documents$: DataDocuments$;
expandedDoc?: ElasticSearchHit;
indexPattern: IndexPattern;
isMobile: () => boolean;
navigateTo: (url: string) => void;
onAddFilter: DocViewFilterFn;
savedSearch: SavedSearch;
services: DiscoverServices;
setExpandedDoc: (doc: ElasticSearchHit | undefined) => void;
state: AppState;
stateContainer: GetStateReturn;
}) {
const { capabilities, indexPatterns, uiSettings } = services;
const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]);
const scrollableDesktop = useRef<HTMLDivElement>(null);
const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]);
const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]);
const documentState: DataDocumentsMsg = useDataState(documents$);
const rows = useMemo(() => documentState.result || [], [documentState.result]);
const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({
capabilities,
config: uiSettings,
indexPattern,
indexPatterns,
setAppState: stateContainer.setAppState,
state,
useNewFieldsApi,
});
/**
* Legacy function, remove once legacy grid is removed
*/
const onBackToTop = useCallback(() => {
if (scrollableDesktop && scrollableDesktop.current) {
scrollableDesktop.current.focus();
}
// Only the desktop one needs to target a specific container
if (!isMobile() && scrollableDesktop.current) {
scrollableDesktop.current.scrollTo(0, 0);
} else if (window) {
window.scrollTo(0, 0);
}
}, [scrollableDesktop, isMobile]);
const onResize = useCallback(
(colSettings: { columnId: string; width: number }) => {
const grid = { ...state.grid } || {};
const newColumns = { ...grid.columns } || {};
newColumns[colSettings.columnId] = {
width: colSettings.width,
};
const newGrid = { ...grid, columns: newColumns };
stateContainer.setAppState({ grid: newGrid });
},
[stateContainer, state]
);
const onSort = useCallback(
(sort: string[][]) => {
stateContainer.setAppState({ sort });
},
[stateContainer]
);
const showTimeCol = useMemo(
() => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName,
[uiSettings, indexPattern.timeFieldName]
);
if (
(!documentState.result || documentState.result.length === 0) &&
documentState.fetchStatus === FetchStatus.LOADING
) {
return (
<div className="dscDocuments__loading">
<EuiText size="xs" color="subdued">
<EuiLoadingSpinner />
<EuiSpacer size="s" />
<FormattedMessage id="discover.loadingDocuments" defaultMessage="Loading documents" />
</EuiText>
</div>
);
}
return (
<EuiFlexItem className="eui-yScroll">
<section
className="dscTable eui-yScroll eui-xScroll"
aria-labelledby="documentsAriaLabel"
ref={scrollableDesktop}
tabIndex={-1}
>
<h2 className="euiScreenReaderOnly" id="documentsAriaLabel">
<FormattedMessage id="discover.documentsAriaLabel" defaultMessage="Documents" />
</h2>
{isLegacy && rows && rows.length && (
<DocTableLegacyMemoized
columns={columns}
indexPattern={indexPattern}
rows={rows}
sort={state.sort || []}
searchDescription={savedSearch.description}
searchTitle={savedSearch.lastSavedTitle}
onAddColumn={onAddColumn}
onBackToTop={onBackToTop}
onFilter={onAddFilter}
onMoveColumn={onMoveColumn}
onRemoveColumn={onRemoveColumn}
onSort={onSort}
sampleSize={sampleSize}
useNewFieldsApi={useNewFieldsApi}
/>
)}
{!isLegacy && (
<div className="dscDiscoverGrid">
<DataGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={columns}
expandedDoc={expandedDoc}
indexPattern={indexPattern}
isLoading={documentState.fetchStatus === FetchStatus.LOADING}
rows={rows}
sort={(state.sort as SortPairArr[]) || []}
sampleSize={sampleSize}
searchDescription={savedSearch.description}
searchTitle={savedSearch.lastSavedTitle}
setExpandedDoc={setExpandedDoc}
showTimeCol={showTimeCol}
services={services}
settings={state.grid}
onAddColumn={onAddColumn}
onFilter={onAddFilter as DocViewFilterFn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
onSort={onSort}
onResize={onResize}
useNewFieldsApi={useNewFieldsApi}
/>
</div>
)}
</section>
</EuiFlexItem>
);
}
export const DiscoverDocuments = memo(DiscoverDocumentsComponent);

View file

@ -103,3 +103,12 @@ discover-app {
padding: $euiSizeXS $euiSizeS; padding: $euiSizeXS $euiSizeS;
text-align: center; text-align: center;
} }
.dscDocuments__loading {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
height: 100%;
width: 100%;
}

View file

@ -20,9 +20,17 @@ import { SavedObject } from '../../../../../../../../core/types';
import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield';
import { GetStateReturn } from '../../services/discover_state'; import { GetStateReturn } from '../../services/discover_state';
import { DiscoverLayoutProps } from './types'; import { DiscoverLayoutProps } from './types';
import { SavedSearchDataSubject } from '../../services/use_saved_search'; import {
DataCharts$,
DataDocuments$,
DataMain$,
DataTotalHits$,
} from '../../services/use_saved_search';
import { discoverServiceMock } from '../../../../../__mocks__/services'; import { discoverServiceMock } from '../../../../../__mocks__/services';
import { FetchStatus } from '../../../../types'; import { FetchStatus } from '../../../../types';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { RequestAdapter } from '../../../../../../../inspector';
import { Chart } from '../chart/point_series';
setHeaderActionMenuMounter(jest.fn()); setHeaderActionMenuMounter(jest.fn());
@ -37,23 +45,99 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps {
return { ...ip, ...{ attributes: { title: ip.title } } }; return { ...ip, ...{ attributes: { title: ip.title } } };
}) as unknown) as Array<SavedObject<IndexPatternAttributes>>; }) as unknown) as Array<SavedObject<IndexPatternAttributes>>;
const savedSearch$ = new BehaviorSubject({ const main$ = new BehaviorSubject({
state: FetchStatus.COMPLETE, fetchStatus: FetchStatus.COMPLETE,
rows: esHits, foundDocuments: true,
fetchCounter: 1, }) as DataMain$;
fieldCounts: {},
hits: Number(esHits.length), const documents$ = new BehaviorSubject({
}) as SavedSearchDataSubject; fetchStatus: FetchStatus.COMPLETE,
result: esHits as ElasticSearchHit[],
}) as DataDocuments$;
const totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: Number(esHits.length),
}) as DataTotalHits$;
const chartData = ({
xAxisOrderedValues: [
1623880800000,
1623967200000,
1624053600000,
1624140000000,
1624226400000,
1624312800000,
1624399200000,
1624485600000,
1624572000000,
1624658400000,
1624744800000,
1624831200000,
1624917600000,
1625004000000,
1625090400000,
],
xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
xAxisLabel: 'order_date per day',
yAxisFormat: { id: 'number' },
ordered: {
date: true,
interval: {
asMilliseconds: jest.fn(),
},
intervalESUnit: 'd',
intervalESValue: 1,
min: '2021-03-18T08:28:56.411Z',
max: '2021-07-01T07:28:56.411Z',
},
yAxisLabel: 'Count',
values: [
{ x: 1623880800000, y: 134 },
{ x: 1623967200000, y: 152 },
{ x: 1624053600000, y: 141 },
{ x: 1624140000000, y: 138 },
{ x: 1624226400000, y: 142 },
{ x: 1624312800000, y: 157 },
{ x: 1624399200000, y: 149 },
{ x: 1624485600000, y: 146 },
{ x: 1624572000000, y: 170 },
{ x: 1624658400000, y: 137 },
{ x: 1624744800000, y: 150 },
{ x: 1624831200000, y: 144 },
{ x: 1624917600000, y: 147 },
{ x: 1625004000000, y: 137 },
{ x: 1625090400000, y: 66 },
],
} as unknown) as Chart;
const charts$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
chartData,
bucketInterval: {
scaled: true,
description: 'test',
scale: 2,
},
}) as DataCharts$;
const savedSearchData$ = {
main$,
documents$,
totalHits$,
charts$,
};
return { return {
indexPattern, indexPattern,
indexPatternList, indexPatternList,
inspectorAdapters: { requests: new RequestAdapter() },
navigateTo: jest.fn(), navigateTo: jest.fn(),
onChangeIndexPattern: jest.fn(), onChangeIndexPattern: jest.fn(),
onUpdateQuery: jest.fn(), onUpdateQuery: jest.fn(),
resetQuery: jest.fn(), resetQuery: jest.fn(),
savedSearch: savedSearchMock, savedSearch: savedSearchMock,
savedSearchData$: savedSearch$, savedSearchData$,
savedSearchRefetch$: new Subject(), savedSearchRefetch$: new Subject(),
searchSource: searchSourceMock, searchSource: searchSourceMock,
services, services,

View file

@ -20,11 +20,10 @@ import {
} from '@elastic/eui'; } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics'; import { METRIC_TYPE } from '@kbn/analytics';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { I18nProvider } from '@kbn/i18n/react';
import classNames from 'classnames'; import classNames from 'classnames';
import { DiscoverNoResults } from '../no_results'; import { DiscoverNoResults } from '../no_results';
import { LoadingSpinner } from '../loading_spinner/loading_spinner'; import { LoadingSpinner } from '../loading_spinner/loading_spinner';
import { DocTableLegacy } from '../../../../angular/doc_table/create_doc_table_react';
import { import {
esFilters, esFilters,
IndexPatternField, IndexPatternField,
@ -32,42 +31,28 @@ import {
} from '../../../../../../../data/public'; } from '../../../../../../../data/public';
import { DiscoverSidebarResponsive } from '../sidebar'; import { DiscoverSidebarResponsive } from '../sidebar';
import { DiscoverLayoutProps } from './types'; import { DiscoverLayoutProps } from './types';
import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../../../common';
import {
DOC_HIDE_TIME_COLUMN_SETTING,
DOC_TABLE_LEGACY,
SAMPLE_SIZE_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
} from '../../../../../../common';
import { popularizeField } from '../../../../helpers/popularize_field'; import { popularizeField } from '../../../../helpers/popularize_field';
import { DocViewFilterFn } from '../../../../doc_views/doc_views_types';
import { DiscoverGrid } from '../../../../components/discover_grid/discover_grid';
import { DiscoverTopNav } from '../top_nav/discover_topnav'; import { DiscoverTopNav } from '../top_nav/discover_topnav';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { DiscoverChart } from '../chart'; import { DiscoverChart } from '../chart';
import { getResultState } from '../../utils/get_result_state'; import { getResultState } from '../../utils/get_result_state';
import { InspectorSession } from '../../../../../../../inspector/public'; import { InspectorSession } from '../../../../../../../inspector/public';
import { DiscoverUninitialized } from '../uninitialized/uninitialized'; import { DiscoverUninitialized } from '../uninitialized/uninitialized';
import { SavedSearchDataMessage } from '../../services/use_saved_search'; import { DataMainMsg } from '../../services/use_saved_search';
import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns';
import { DiscoverDocuments } from './discover_documents';
import { FetchStatus } from '../../../../types'; import { FetchStatus } from '../../../../types';
import { useDataState } from '../../utils/use_data_state';
const DocTableLegacyMemoized = React.memo(DocTableLegacy);
const SidebarMemoized = React.memo(DiscoverSidebarResponsive); const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const DataGridMemoized = React.memo(DiscoverGrid);
const TopNavMemoized = React.memo(DiscoverTopNav); const TopNavMemoized = React.memo(DiscoverTopNav);
const DiscoverChartMemoized = React.memo(DiscoverChart); const DiscoverChartMemoized = React.memo(DiscoverChart);
interface DiscoverLayoutFetchState extends SavedSearchDataMessage {
state: FetchStatus;
fetchCounter: number;
fieldCounts: Record<string, number>;
rows: ElasticSearchHit[];
}
export function DiscoverLayout({ export function DiscoverLayout({
indexPattern, indexPattern,
indexPatternList, indexPatternList,
inspectorAdapters,
navigateTo, navigateTo,
onChangeIndexPattern, onChangeIndexPattern,
onUpdateQuery, onUpdateQuery,
@ -82,38 +67,22 @@ export function DiscoverLayout({
}: DiscoverLayoutProps) { }: DiscoverLayoutProps) {
const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services; const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services;
const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]);
const [expandedDoc, setExpandedDoc] = useState<ElasticSearchHit | undefined>(undefined); const [expandedDoc, setExpandedDoc] = useState<ElasticSearchHit | undefined>(undefined);
const [inspectorSession, setInspectorSession] = useState<InspectorSession | undefined>(undefined); const [inspectorSession, setInspectorSession] = useState<InspectorSession | undefined>(undefined);
const scrollableDesktop = useRef<HTMLDivElement>(null);
const collapseIcon = useRef<HTMLButtonElement>(null); const collapseIcon = useRef<HTMLButtonElement>(null);
const fetchCounter = useRef<number>(0);
const { main$, charts$, totalHits$ } = savedSearchData$;
const [fetchState, setFetchState] = useState<DiscoverLayoutFetchState>({ const dataState: DataMainMsg = useDataState(main$);
state: savedSearchData$.getValue().state,
fetchCounter: 0,
fieldCounts: {},
rows: [],
});
const { state: fetchStatus, fetchCounter, inspectorAdapters, rows } = fetchState;
useEffect(() => { useEffect(() => {
const subscription = savedSearchData$.subscribe((next) => { if (dataState.fetchStatus === FetchStatus.LOADING) {
if ( fetchCounter.current++;
(next.state && next.state !== fetchState.state) || }
(next.fetchCounter && next.fetchCounter !== fetchState.fetchCounter) || }, [dataState.fetchStatus]);
(next.rows && next.rows !== fetchState.rows) ||
(next.chartData && next.chartData !== fetchState.chartData)
) {
setFetchState({ ...fetchState, ...next });
}
});
return () => {
subscription.unsubscribe();
};
}, [savedSearchData$, fetchState]);
// collapse icon isn't displayed in mobile view, use it to detect which view is displayed // collapse icon isn't displayed in mobile view, use it to detect which view is displayed
const isMobile = () => collapseIcon && !collapseIcon.current; const isMobile = useCallback(() => collapseIcon && !collapseIcon.current, []);
const timeField = useMemo(() => { const timeField = useMemo(() => {
return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined;
}, [indexPattern]); }, [indexPattern]);
@ -122,27 +91,18 @@ export function DiscoverLayout({
const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]);
const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]);
const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]); const resultState = useMemo(
() => getResultState(dataState.fetchStatus, dataState.foundDocuments!),
const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ [dataState.fetchStatus, dataState.foundDocuments]
capabilities, );
config: uiSettings,
indexPattern,
indexPatterns,
setAppState: stateContainer.setAppState,
state,
useNewFieldsApi,
});
const onOpenInspector = useCallback(() => { const onOpenInspector = useCallback(() => {
// prevent overlapping // prevent overlapping
if (inspectorAdapters) { setExpandedDoc(undefined);
setExpandedDoc(undefined); const session = services.inspector.open(inspectorAdapters, {
const session = services.inspector.open(inspectorAdapters, { title: savedSearch.title,
title: savedSearch.title, });
}); setInspectorSession(session);
setInspectorSession(session);
}
}, [setExpandedDoc, inspectorAdapters, savedSearch, services.inspector]); }, [setExpandedDoc, inspectorAdapters, savedSearch, services.inspector]);
useEffect(() => { useEffect(() => {
@ -154,12 +114,15 @@ export function DiscoverLayout({
}; };
}, [inspectorSession]); }, [inspectorSession]);
const onSort = useCallback( const { columns, onAddColumn, onRemoveColumn } = useDataGridColumns({
(sort: string[][]) => { capabilities,
stateContainer.setAppState({ sort }); config: uiSettings,
}, indexPattern,
[stateContainer] indexPatterns,
); setAppState: stateContainer.setAppState,
state,
useNewFieldsApi,
});
const onAddFilter = useCallback( const onAddFilter = useCallback(
(field: IndexPatternField | string, values: string, operation: '+' | '-') => { (field: IndexPatternField | string, values: string, operation: '+' | '-') => {
@ -179,33 +142,6 @@ export function DiscoverLayout({
}, },
[filterManager, indexPattern, indexPatterns, trackUiMetric] [filterManager, indexPattern, indexPatterns, trackUiMetric]
); );
/**
* Legacy function, remove once legacy grid is removed
*/
const onBackToTop = useCallback(() => {
if (scrollableDesktop && scrollableDesktop.current) {
scrollableDesktop.current.focus();
}
// Only the desktop one needs to target a specific container
if (!isMobile() && scrollableDesktop.current) {
scrollableDesktop.current.scrollTo(0, 0);
} else if (window) {
window.scrollTo(0, 0);
}
}, [scrollableDesktop]);
const onResize = useCallback(
(colSettings: { columnId: string; width: number }) => {
const grid = { ...state.grid } || {};
const newColumns = { ...grid.columns } || {};
newColumns[colSettings.columnId] = {
width: colSettings.width,
};
const newGrid = { ...grid, columns: newColumns };
stateContainer.setAppState({ grid: newGrid });
},
[stateContainer, state]
);
const onEditRuntimeField = useCallback(() => { const onEditRuntimeField = useCallback(() => {
savedSearchRefetch$.next('reset'); savedSearchRefetch$.next('reset');
@ -219,14 +155,10 @@ export function DiscoverLayout({
}, [filterManager]); }, [filterManager]);
const contentCentered = resultState === 'uninitialized' || resultState === 'none'; const contentCentered = resultState === 'uninitialized' || resultState === 'none';
const showTimeCol = useMemo(
() => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName,
[uiSettings, indexPattern.timeFieldName]
);
return ( return (
<I18nProvider> <I18nProvider>
<EuiPage className="dscPage" data-fetch-counter={fetchCounter}> <EuiPage className="dscPage" data-fetch-counter={fetchCounter.current}>
<TopNavMemoized <TopNavMemoized
indexPattern={indexPattern} indexPattern={indexPattern}
onOpenInspector={onOpenInspector} onOpenInspector={onOpenInspector}
@ -247,8 +179,7 @@ export function DiscoverLayout({
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<SidebarMemoized <SidebarMemoized
columns={columns} columns={columns}
fieldCounts={fetchState.fieldCounts} documents$={savedSearchData$.documents$}
hits={rows}
indexPatternList={indexPatternList} indexPatternList={indexPatternList}
onAddField={onAddColumn} onAddField={onAddColumn}
onAddFilter={onAddFilter} onAddFilter={onAddFilter}
@ -298,7 +229,7 @@ export function DiscoverLayout({
<DiscoverNoResults <DiscoverNoResults
timeFieldName={timeField} timeFieldName={timeField}
data={data} data={data}
error={fetchState.fetchError} error={dataState.error}
hasQuery={!!state.query?.query} hasQuery={!!state.query?.query}
hasFilters={ hasFilters={
state.filters && state.filters.filter((f) => !f.meta.disabled).length > 0 state.filters && state.filters.filter((f) => !f.meta.disabled).length > 0
@ -320,82 +251,31 @@ export function DiscoverLayout({
> >
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<DiscoverChartMemoized <DiscoverChartMemoized
config={uiSettings}
chartData={fetchState.chartData}
bucketInterval={fetchState.bucketInterval}
data={data}
hits={fetchState.hits}
indexPattern={indexPattern}
isLegacy={isLegacy} isLegacy={isLegacy}
state={state} state={state}
resetQuery={resetQuery} resetQuery={resetQuery}
savedSearch={savedSearch} savedSearch={savedSearch}
savedSearchDataChart$={charts$}
savedSearchDataTotalHits$={totalHits$}
services={services}
stateContainer={stateContainer} stateContainer={stateContainer}
timefield={timeField} timefield={timeField}
/> />
</EuiFlexItem> </EuiFlexItem>
<EuiHorizontalRule margin="none" /> <EuiHorizontalRule margin="none" />
<DiscoverDocuments
<EuiFlexItem className="eui-yScroll"> documents$={savedSearchData$.documents$}
<section expandedDoc={expandedDoc}
className="dscTable eui-yScroll eui-xScroll" indexPattern={indexPattern}
aria-labelledby="documentsAriaLabel" isMobile={isMobile}
ref={scrollableDesktop} navigateTo={navigateTo}
tabIndex={-1} onAddFilter={onAddFilter as DocViewFilterFn}
> savedSearch={savedSearch}
<h2 className="euiScreenReaderOnly" id="documentsAriaLabel"> services={services}
<FormattedMessage setExpandedDoc={setExpandedDoc}
id="discover.documentsAriaLabel" state={state}
defaultMessage="Documents" stateContainer={stateContainer}
/> />
</h2>
{isLegacy && rows && rows.length && (
<DocTableLegacyMemoized
columns={columns}
indexPattern={indexPattern}
rows={rows}
sort={state.sort || []}
searchDescription={savedSearch.description}
searchTitle={savedSearch.lastSavedTitle}
onAddColumn={onAddColumn}
onBackToTop={onBackToTop}
onFilter={onAddFilter}
onMoveColumn={onMoveColumn}
onRemoveColumn={onRemoveColumn}
onSort={onSort}
sampleSize={sampleSize}
useNewFieldsApi={useNewFieldsApi}
/>
)}
{!isLegacy && rows && rows.length && (
<div className="dscDiscoverGrid">
<DataGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={columns}
expandedDoc={expandedDoc}
indexPattern={indexPattern}
isLoading={fetchStatus === 'loading'}
rows={rows}
sort={(state.sort as SortPairArr[]) || []}
sampleSize={sampleSize}
searchDescription={savedSearch.description}
searchTitle={savedSearch.lastSavedTitle}
setExpandedDoc={setExpandedDoc}
showTimeCol={showTimeCol}
services={services}
settings={state.grid}
onAddColumn={onAddColumn}
onFilter={onAddFilter as DocViewFilterFn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
onSort={onSort}
onResize={onResize}
useNewFieldsApi={useNewFieldsApi}
/>
</div>
)}
</section>
</EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
)} )}
</EuiPageContent> </EuiPageContent>

View file

@ -15,20 +15,22 @@ import {
} from '../../../../../../../data/common'; } from '../../../../../../../data/common';
import { ISearchSource } from '../../../../../../../data/public'; import { ISearchSource } from '../../../../../../../data/public';
import { AppState, GetStateReturn } from '../../services/discover_state'; import { AppState, GetStateReturn } from '../../services/discover_state';
import { SavedSearchRefetchSubject, SavedSearchDataSubject } from '../../services/use_saved_search'; import { DataRefetch$, SavedSearchData } from '../../services/use_saved_search';
import { DiscoverServices } from '../../../../../build_services'; import { DiscoverServices } from '../../../../../build_services';
import { SavedSearch } from '../../../../../saved_searches'; import { SavedSearch } from '../../../../../saved_searches';
import { RequestAdapter } from '../../../../../../../inspector';
export interface DiscoverLayoutProps { export interface DiscoverLayoutProps {
indexPattern: IndexPattern; indexPattern: IndexPattern;
indexPatternList: Array<SavedObject<IndexPatternAttributes>>; indexPatternList: Array<SavedObject<IndexPatternAttributes>>;
inspectorAdapters: { requests: RequestAdapter };
navigateTo: (url: string) => void; navigateTo: (url: string) => void;
onChangeIndexPattern: (id: string) => void; onChangeIndexPattern: (id: string) => void;
onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void;
resetQuery: () => void; resetQuery: () => void;
savedSearch: SavedSearch; savedSearch: SavedSearch;
savedSearchData$: SavedSearchDataSubject; savedSearchData$: SavedSearchData;
savedSearchRefetch$: SavedSearchRefetchSubject; savedSearchRefetch$: DataRefetch$;
searchSource: ISearchSource; searchSource: ISearchSource;
services: DiscoverServices; services: DiscoverServices;
state: AppState; state: AppState;

View file

@ -63,7 +63,7 @@ function getCompProps(): DiscoverSidebarProps {
return { return {
columns: ['extension'], columns: ['extension'],
fieldCounts, fieldCounts,
hits, documents: hits,
indexPatternList, indexPatternList,
onChangeIndexPattern: jest.fn(), onChangeIndexPattern: jest.fn(),
onAddFilter: jest.fn(), onAddFilter: jest.fn(),

View file

@ -36,13 +36,14 @@ import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './l
import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list';
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
/** /**
* Default number of available fields displayed and added on scroll * Default number of available fields displayed and added on scroll
*/ */
const FIELDS_PER_PAGE = 50; const FIELDS_PER_PAGE = 50;
export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProps, 'documents$'> {
/** /**
* Current state of the field filter, filtering fields by name, type, ... * Current state of the field filter, filtering fields by name, type, ...
*/ */
@ -64,6 +65,15 @@ export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
setFieldEditorRef?: (ref: () => void | undefined) => void; setFieldEditorRef?: (ref: () => void | undefined) => void;
editField: (fieldName?: string) => void; editField: (fieldName?: string) => void;
/**
* a statistics of the distribution of fields in the given hits
*/
fieldCounts: Record<string, number>;
/**
* hits fetched from ES, displayed in the doc table
*/
documents: ElasticSearchHit[];
} }
export function DiscoverSidebar({ export function DiscoverSidebar({
@ -71,7 +81,7 @@ export function DiscoverSidebar({
columns, columns,
fieldCounts, fieldCounts,
fieldFilter, fieldFilter,
hits, documents,
indexPatternList, indexPatternList,
onAddField, onAddField,
onAddFilter, onAddFilter,
@ -101,7 +111,7 @@ export function DiscoverSidebar({
useEffect(() => { useEffect(() => {
const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts);
setFields(newFields); setFields(newFields);
}, [selectedIndexPattern, fieldCounts, hits]); }, [selectedIndexPattern, fieldCounts, documents]);
const scrollDimensions = useResizeObserver(scrollContainer); const scrollDimensions = useResizeObserver(scrollContainer);
@ -115,8 +125,8 @@ export function DiscoverSidebar({
); );
const getDetailsByField = useCallback( const getDetailsByField = useCallback(
(ipField: IndexPatternField) => getDetails(ipField, hits, columns, selectedIndexPattern), (ipField: IndexPatternField) => getDetails(ipField, documents, columns, selectedIndexPattern),
[hits, columns, selectedIndexPattern] [documents, columns, selectedIndexPattern]
); );
const popularLimit = useMemo(() => services.uiSettings.get(FIELDS_LIMIT_SETTING), [ const popularLimit = useMemo(() => services.uiSettings.get(FIELDS_LIMIT_SETTING), [

View file

@ -7,6 +7,7 @@
*/ */
import { each, cloneDeep } from 'lodash'; import { each, cloneDeep } from 'lodash';
import { BehaviorSubject } from 'rxjs';
import { ReactWrapper } from 'enzyme'; import { ReactWrapper } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test'; import { findTestSubject } from '@elastic/eui/lib/test';
// @ts-expect-error // @ts-expect-error
@ -25,6 +26,8 @@ import {
} from './discover_sidebar_responsive'; } from './discover_sidebar_responsive';
import { DiscoverServices } from '../../../../../build_services'; import { DiscoverServices } from '../../../../../build_services';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../../types';
import { DataDocuments$ } from '../../services/use_saved_search';
const mockServices = ({ const mockServices = ({
history: () => ({ history: () => ({
@ -86,8 +89,10 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
} }
return { return {
columns: ['extension'], columns: ['extension'],
fieldCounts, documents$: new BehaviorSubject({
hits, fetchStatus: FetchStatus.COMPLETE,
result: hits as ElasticSearchHit[],
}) as DataDocuments$,
indexPatternList, indexPatternList,
onChangeIndexPattern: jest.fn(), onChangeIndexPattern: jest.fn(),
onAddFilter: jest.fn(), onAddFilter: jest.fn(),

View file

@ -6,7 +6,7 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
@ -33,9 +33,10 @@ import { IndexPatternField, IndexPattern } from '../../../../../../../data/publi
import { getDefaultFieldFilter } from './lib/field_filter'; import { getDefaultFieldFilter } from './lib/field_filter';
import { DiscoverSidebar } from './discover_sidebar'; import { DiscoverSidebar } from './discover_sidebar';
import { DiscoverServices } from '../../../../../build_services'; import { DiscoverServices } from '../../../../../build_services';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { AppState } from '../../services/discover_state'; import { AppState } from '../../services/discover_state';
import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management';
import { DataDocuments$ } from '../../services/use_saved_search';
import { calcFieldCounts } from '../../utils/calc_field_counts';
export interface DiscoverSidebarResponsiveProps { export interface DiscoverSidebarResponsiveProps {
/** /**
@ -46,14 +47,10 @@ export interface DiscoverSidebarResponsiveProps {
* the selected columns displayed in the doc table in discover * the selected columns displayed in the doc table in discover
*/ */
columns: string[]; columns: string[];
/**
* a statistics of the distribution of fields in the given hits
*/
fieldCounts: Record<string, number>;
/** /**
* hits fetched from ES, displayed in the doc table * hits fetched from ES, displayed in the doc table
*/ */
hits: ElasticSearchHit[]; documents$: DataDocuments$;
/** /**
* List of available index patterns * List of available index patterns
*/ */
@ -119,6 +116,36 @@ export interface DiscoverSidebarResponsiveProps {
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter()); const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter());
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
/**
* needed for merging new with old field counts, high likely legacy, but kept this behavior
* because not 100% sure in this case
*/
const fieldCounts = useRef<Record<string, number>>(
calcFieldCounts({}, props.documents$.getValue().result, props.selectedIndexPattern)
);
const [documentState, setDocumentState] = useState(props.documents$.getValue());
useEffect(() => {
const subscription = props.documents$.subscribe((next) => {
if (next.fetchStatus !== documentState.fetchStatus) {
if (next.result) {
fieldCounts.current = calcFieldCounts(
next.result.length ? fieldCounts.current : {},
next.result,
props.selectedIndexPattern!
);
}
setDocumentState({ ...documentState, ...next });
}
});
return () => subscription.unsubscribe();
}, [props.documents$, props.selectedIndexPattern, documentState, setDocumentState]);
useEffect(() => {
// when index pattern changes fieldCounts needs to be cleaned up to prevent displaying
// fields of the previous index pattern
fieldCounts.current = {};
}, [props.selectedIndexPattern]);
const closeFieldEditor = useRef<() => void | undefined>(); const closeFieldEditor = useRef<() => void | undefined>();
@ -134,18 +161,18 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
}; };
}, []); }, []);
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const closeFlyout = useCallback(() => {
setIsFlyoutVisible(false);
}, []);
if (!props.selectedIndexPattern) { if (!props.selectedIndexPattern) {
return null; return null;
} }
const setFieldEditorRef = (ref: () => void | undefined) => {
closeFieldEditor.current = ref;
};
const closeFlyout = () => {
setIsFlyoutVisible(false);
};
const { indexPatternFieldEditor } = props.services; const { indexPatternFieldEditor } = props.services;
const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern();
const canEditIndexPatternField = !!indexPatternFieldEditPermission && props.useNewFieldsApi; const canEditIndexPatternField = !!indexPatternFieldEditPermission && props.useNewFieldsApi;
@ -177,7 +204,9 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
<EuiHideFor sizes={['xs', 's']}> <EuiHideFor sizes={['xs', 's']}>
<DiscoverSidebar <DiscoverSidebar
{...props} {...props}
documents={documentState.result ?? []}
fieldFilter={fieldFilter} fieldFilter={fieldFilter}
fieldCounts={fieldCounts.current}
setFieldFilter={setFieldFilter} setFieldFilter={setFieldFilter}
editField={editField} editField={editField}
/> />
@ -262,6 +291,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
<div className="euiFlyoutBody"> <div className="euiFlyoutBody">
<DiscoverSidebar <DiscoverSidebar
{...props} {...props}
documents={documentState.result ?? []}
fieldCounts={fieldCounts.current}
fieldFilter={fieldFilter} fieldFilter={fieldFilter}
setFieldFilter={setFieldFilter} setFieldFilter={setFieldFilter}
alwaysShowActionButtons={true} alwaysShowActionButtons={true}

View file

@ -13,7 +13,61 @@ import { TimechartHeader, TimechartHeaderProps } from './timechart_header';
import { EuiIconTip } from '@elastic/eui'; import { EuiIconTip } from '@elastic/eui';
import { findTestSubject } from '@elastic/eui/lib/test'; import { findTestSubject } from '@elastic/eui/lib/test';
import { DataPublicPluginStart } from '../../../../../../../data/public'; import { DataPublicPluginStart } from '../../../../../../../data/public';
import { FetchStatus } from '../../../../types';
import { BehaviorSubject } from 'rxjs';
import { Chart } from '../chart/point_series';
import { DataCharts$ } from '../../services/use_saved_search';
const chartData = ({
xAxisOrderedValues: [
1623880800000,
1623967200000,
1624053600000,
1624140000000,
1624226400000,
1624312800000,
1624399200000,
1624485600000,
1624572000000,
1624658400000,
1624744800000,
1624831200000,
1624917600000,
1625004000000,
1625090400000,
],
xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
xAxisLabel: 'order_date per day',
yAxisFormat: { id: 'number' },
ordered: {
date: true,
interval: {
asMilliseconds: jest.fn(),
},
intervalESUnit: 'd',
intervalESValue: 1,
min: '2021-03-18T08:28:56.411Z',
max: '2021-07-01T07:28:56.411Z',
},
yAxisLabel: 'Count',
values: [
{ x: 1623880800000, y: 134 },
{ x: 1623967200000, y: 152 },
{ x: 1624053600000, y: 141 },
{ x: 1624140000000, y: 138 },
{ x: 1624226400000, y: 142 },
{ x: 1624312800000, y: 157 },
{ x: 1624399200000, y: 149 },
{ x: 1624485600000, y: 146 },
{ x: 1624572000000, y: 170 },
{ x: 1624658400000, y: 137 },
{ x: 1624744800000, y: 150 },
{ x: 1624831200000, y: 144 },
{ x: 1624917600000, y: 147 },
{ x: 1625004000000, y: 137 },
{ x: 1625090400000, y: 66 },
],
} as unknown) as Chart;
describe('timechart header', function () { describe('timechart header', function () {
let props: TimechartHeaderProps; let props: TimechartHeaderProps;
let component: ReactWrapper<TimechartHeaderProps>; let component: ReactWrapper<TimechartHeaderProps>;
@ -48,11 +102,16 @@ describe('timechart header', function () {
}, },
], ],
onChangeInterval: jest.fn(), onChangeInterval: jest.fn(),
bucketInterval: {
scaled: undefined, savedSearchData$: new BehaviorSubject({
description: 'second', fetchStatus: FetchStatus.COMPLETE,
scale: undefined, chartData,
}, bucketInterval: {
scaled: false,
description: 'second',
scale: undefined,
},
}) as DataCharts$,
}; };
}); });
@ -62,7 +121,15 @@ describe('timechart header', function () {
}); });
it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => { it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => {
props.bucketInterval!.scaled = true; props.savedSearchData$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
chartData,
bucketInterval: {
scaled: true,
description: 'second',
scale: undefined,
},
}) as DataCharts$;
component = mountWithIntl(<TimechartHeader {...props} />); component = mountWithIntl(<TimechartHeader {...props} />);
expect(component.find(EuiIconTip).length).toBe(1); expect(component.find(EuiIconTip).length).toBe(1);
}); });

View file

@ -20,6 +20,8 @@ import { i18n } from '@kbn/i18n';
import dateMath from '@elastic/datemath'; import dateMath from '@elastic/datemath';
import './timechart_header.scss'; import './timechart_header.scss';
import { DataPublicPluginStart } from '../../../../../../../data/public'; import { DataPublicPluginStart } from '../../../../../../../data/public';
import { DataCharts$, DataChartsMessage } from '../../services/use_saved_search';
import { useDataState } from '../../utils/use_data_state';
export interface TimechartBucketInterval { export interface TimechartBucketInterval {
scaled?: boolean; scaled?: boolean;
@ -32,10 +34,7 @@ export interface TimechartHeaderProps {
* Format of date to be displayed * Format of date to be displayed
*/ */
dateFormat?: string; dateFormat?: string;
/**
* Interval for the buckets of the recent request
*/
bucketInterval?: TimechartBucketInterval;
data: DataPublicPluginStart; data: DataPublicPluginStart;
/** /**
* Interval Options * Interval Options
@ -49,17 +48,23 @@ export interface TimechartHeaderProps {
* selected interval * selected interval
*/ */
stateInterval: string; stateInterval: string;
savedSearchData$: DataCharts$;
} }
export function TimechartHeader({ export function TimechartHeader({
bucketInterval,
dateFormat, dateFormat,
data, data: dataPluginStart,
options, options,
onChangeInterval, onChangeInterval,
stateInterval, stateInterval,
savedSearchData$,
}: TimechartHeaderProps) { }: TimechartHeaderProps) {
const { timefilter } = data.query.timefilter; const { timefilter } = dataPluginStart.query.timefilter;
const data: DataChartsMessage = useDataState(savedSearchData$);
const { bucketInterval } = data;
const { from, to } = timefilter.getTime(); const { from, to } = timefilter.getTime();
const timeRange = { const timeRange = {
from: dateMath.parse(from), from: dateMath.parse(from),

View file

@ -58,6 +58,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
const { const {
data$, data$,
indexPattern, indexPattern,
inspectorAdapters,
onChangeIndexPattern, onChangeIndexPattern,
onUpdateQuery, onUpdateQuery,
refetch$, refetch$,
@ -105,6 +106,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
<DiscoverLayoutMemoized <DiscoverLayoutMemoized
indexPattern={indexPattern} indexPattern={indexPattern}
indexPatternList={indexPatternList} indexPatternList={indexPatternList}
inspectorAdapters={inspectorAdapters}
onChangeIndexPattern={onChangeIndexPattern} onChangeIndexPattern={onChangeIndexPattern}
onUpdateQuery={onUpdateQuery} onUpdateQuery={onUpdateQuery}
resetQuery={resetQuery} resetQuery={resetQuery}

View file

@ -112,7 +112,7 @@ export interface GetStateReturn {
*/ */
appStateContainer: ReduxLikeStateContainer<AppState>; appStateContainer: ReduxLikeStateContainer<AppState>;
/** /**
* Function starting state sync when Discover main is loaded * Initialize state with filters and query, start state syncing
*/ */
initializeAndSync: ( initializeAndSync: (
indexPattern: IndexPattern, indexPattern: IndexPattern,

View file

@ -41,7 +41,7 @@ export function useDiscoverState({
const [indexPattern, setIndexPattern] = useState(initialIndexPattern); const [indexPattern, setIndexPattern] = useState(initialIndexPattern);
const [savedSearch, setSavedSearch] = useState(initialSavedSearch); const [savedSearch, setSavedSearch] = useState(initialSavedSearch);
const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]);
const timefilter = data.query.timefilter.timefilter; const { timefilter } = data.query.timefilter;
const searchSource = useMemo(() => { const searchSource = useMemo(() => {
savedSearch.searchSource.setField('index', indexPattern); savedSearch.searchSource.setField('index', indexPattern);
@ -88,8 +88,7 @@ export function useDiscoverState({
/** /**
* Data fetching logic * Data fetching logic
*/ */
const { data$, refetch$, reset } = useSavedSearchData({ const { data$, refetch$, reset, inspectorAdapters } = useSavedSearchData({
indexPattern,
initialFetchStatus, initialFetchStatus,
searchSessionManager, searchSessionManager,
searchSource, searchSource,
@ -100,9 +99,7 @@ export function useDiscoverState({
useEffect(() => { useEffect(() => {
const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data);
return () => { return () => stopSync();
stopSync();
};
}, [stateContainer, filterManager, data, indexPattern]); }, [stateContainer, filterManager, data, indexPattern]);
/** /**
@ -138,8 +135,11 @@ export function useDiscoverState({
setState(nextState); setState(nextState);
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [config, indexPatterns, appStateContainer, setState, state, refetch$, data$, reset]); }, [config, indexPatterns, appStateContainer, setState, state, refetch$, reset]);
/**
* function to revert any changes to a given saved search
*/
const resetSavedSearch = useCallback( const resetSavedSearch = useCallback(
async (id?: string) => { async (id?: string) => {
const newSavedSearch = await services.getSavedSearchById(id); const newSavedSearch = await services.getSavedSearchById(id);
@ -201,11 +201,12 @@ export function useDiscoverState({
if (initialFetchStatus === FetchStatus.LOADING) { if (initialFetchStatus === FetchStatus.LOADING) {
refetch$.next(); refetch$.next();
} }
}, [initialFetchStatus, refetch$, indexPattern, data$]); }, [initialFetchStatus, refetch$, indexPattern]);
return { return {
data$, data$,
indexPattern, indexPattern,
inspectorAdapters,
refetch$, refetch$,
resetSavedSearch, resetSavedSearch,
onChangeIndexPattern, onChangeIndexPattern,

View file

@ -28,7 +28,6 @@ describe('test useSavedSearch', () => {
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useSavedSearch({ return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING, initialFetchStatus: FetchStatus.LOADING,
searchSessionManager, searchSessionManager,
searchSource: savedSearchMock.searchSource.createCopy(), searchSource: savedSearchMock.searchSource.createCopy(),
@ -39,11 +38,10 @@ describe('test useSavedSearch', () => {
}); });
expect(result.current.refetch$).toBeInstanceOf(Subject); expect(result.current.refetch$).toBeInstanceOf(Subject);
expect(result.current.data$.value).toMatchInlineSnapshot(` expect(result.current.data$.main$.getValue().fetchStatus).toBe(FetchStatus.LOADING);
Object { expect(result.current.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING);
"state": "loading", expect(result.current.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING);
} expect(result.current.data$.charts$.getValue().fetchStatus).toBe(FetchStatus.LOADING);
`);
}); });
test('refetch$ triggers a search', async () => { test('refetch$ triggers a search', async () => {
const { history, searchSessionManager } = createSearchSessionMock(); const { history, searchSessionManager } = createSearchSessionMock();
@ -68,7 +66,6 @@ describe('test useSavedSearch', () => {
const { result, waitForValueToChange } = renderHook(() => { const { result, waitForValueToChange } = renderHook(() => {
return useSavedSearch({ return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING, initialFetchStatus: FetchStatus.LOADING,
searchSessionManager, searchSessionManager,
searchSource: resultState.current.searchSource, searchSource: resultState.current.searchSource,
@ -81,11 +78,11 @@ describe('test useSavedSearch', () => {
result.current.refetch$.next(); result.current.refetch$.next();
await waitForValueToChange(() => { await waitForValueToChange(() => {
return result.current.data$.value.state === 'complete'; return result.current.data$.main$.value.fetchStatus === 'complete';
}); });
expect(result.current.data$.value.hits).toBe(0); expect(result.current.data$.totalHits$.value.result).toBe(0);
expect(result.current.data$.value.rows).toEqual([]); expect(result.current.data$.documents$.value.result).toEqual([]);
}); });
test('reset sets back to initial state', async () => { test('reset sets back to initial state', async () => {
@ -111,7 +108,6 @@ describe('test useSavedSearch', () => {
const { result, waitForValueToChange } = renderHook(() => { const { result, waitForValueToChange } = renderHook(() => {
return useSavedSearch({ return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING, initialFetchStatus: FetchStatus.LOADING,
searchSessionManager, searchSessionManager,
searchSource: resultState.current.searchSource, searchSource: resultState.current.searchSource,
@ -124,10 +120,10 @@ describe('test useSavedSearch', () => {
result.current.refetch$.next(); result.current.refetch$.next();
await waitForValueToChange(() => { await waitForValueToChange(() => {
return result.current.data$.value.state === FetchStatus.COMPLETE; return result.current.data$.main$.value.fetchStatus === FetchStatus.COMPLETE;
}); });
result.current.reset(); result.current.reset();
expect(result.current.data$.value.state).toBe(FetchStatus.LOADING); expect(result.current.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING);
}); });
}); });

View file

@ -5,53 +5,71 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { useEffect, useRef, useCallback } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n'; import { BehaviorSubject, merge, Subject } from 'rxjs';
import { merge, Subject, BehaviorSubject } from 'rxjs'; import { debounceTime, filter, tap } from 'rxjs/operators';
import { debounceTime, tap, filter } from 'rxjs/operators';
import { DiscoverServices } from '../../../../build_services'; import { DiscoverServices } from '../../../../build_services';
import { DiscoverSearchSessionManager } from './discover_search_session'; import { DiscoverSearchSessionManager } from './discover_search_session';
import { import { SearchSource } from '../../../../../../data/common';
IndexPattern,
isCompleteResponse,
SearchSource,
tabifyAggResponse,
} from '../../../../../../data/common';
import { GetStateReturn } from './discover_state'; import { GetStateReturn } from './discover_state';
import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { RequestAdapter } from '../../../../../../inspector/public'; import { RequestAdapter } from '../../../../../../inspector/public';
import { AutoRefreshDoneFn, search } from '../../../../../../data/public'; import { AutoRefreshDoneFn } from '../../../../../../data/public';
import { calcFieldCounts } from '../utils/calc_field_counts';
import { validateTimeRange } from '../utils/validate_time_range'; import { validateTimeRange } from '../utils/validate_time_range';
import { updateSearchSource } from '../utils/update_search_source'; import { Chart } from '../components/chart/point_series';
import { SortOrder } from '../../../../saved_searches/types';
import { getDimensions, getChartAggConfigs } from '../utils';
import { buildPointSeriesData, Chart } from '../components/chart/point_series';
import { TimechartBucketInterval } from '../components/timechart_header/timechart_header'; import { TimechartBucketInterval } from '../components/timechart_header/timechart_header';
import { useSingleton } from '../utils/use_singleton'; import { useSingleton } from '../utils/use_singleton';
import { FetchStatus } from '../../../types'; import { FetchStatus } from '../../../types';
export type SavedSearchDataSubject = BehaviorSubject<SavedSearchDataMessage>; import { fetchAll } from '../utils/fetch_all';
export type SavedSearchRefetchSubject = Subject<SavedSearchRefetchMsg>; import { useBehaviorSubject } from '../utils/use_behavior_subject';
import { sendResetMsg } from './use_saved_search_messages';
export interface UseSavedSearch { export interface SavedSearchData {
refetch$: SavedSearchRefetchSubject; main$: DataMain$;
data$: SavedSearchDataSubject; documents$: DataDocuments$;
reset: () => void; totalHits$: DataTotalHits$;
charts$: DataCharts$;
} }
export type SavedSearchRefetchMsg = 'reset' | undefined; export type DataMain$ = BehaviorSubject<DataMainMsg>;
export type DataDocuments$ = BehaviorSubject<DataDocumentsMsg>;
export type DataTotalHits$ = BehaviorSubject<DataTotalHitsMsg>;
export type DataCharts$ = BehaviorSubject<DataChartsMessage>;
export interface SavedSearchDataMessage { export type DataRefetch$ = Subject<DataRefetchMsg>;
export interface UseSavedSearch {
refetch$: DataRefetch$;
data$: SavedSearchData;
reset: () => void;
inspectorAdapters: { requests: RequestAdapter };
}
export type DataRefetchMsg = 'reset' | undefined;
export interface DataMsg {
fetchStatus: FetchStatus;
error?: Error;
}
export interface DataMainMsg extends DataMsg {
foundDocuments?: boolean;
}
export interface DataDocumentsMsg extends DataMsg {
result?: ElasticSearchHit[];
}
export interface DataTotalHitsMsg extends DataMsg {
fetchStatus: FetchStatus;
error?: Error;
result?: number;
}
export interface DataChartsMessage extends DataMsg {
bucketInterval?: TimechartBucketInterval; bucketInterval?: TimechartBucketInterval;
chartData?: Chart; chartData?: Chart;
fetchCounter?: number;
fetchError?: Error;
fieldCounts?: Record<string, number>;
hits?: number;
inspectorAdapters?: { requests: RequestAdapter };
rows?: ElasticSearchHit[];
state: FetchStatus;
} }
/** /**
@ -59,7 +77,6 @@ export interface SavedSearchDataMessage {
* to the data fetching * to the data fetching
*/ */
export const useSavedSearch = ({ export const useSavedSearch = ({
indexPattern,
initialFetchStatus, initialFetchStatus,
searchSessionManager, searchSessionManager,
searchSource, searchSource,
@ -67,244 +84,148 @@ export const useSavedSearch = ({
stateContainer, stateContainer,
useNewFieldsApi, useNewFieldsApi,
}: { }: {
indexPattern: IndexPattern;
initialFetchStatus: FetchStatus; initialFetchStatus: FetchStatus;
searchSessionManager: DiscoverSearchSessionManager; searchSessionManager: DiscoverSearchSessionManager;
searchSource: SearchSource; searchSource: SearchSource;
services: DiscoverServices; services: DiscoverServices;
stateContainer: GetStateReturn; stateContainer: GetStateReturn;
useNewFieldsApi: boolean; useNewFieldsApi: boolean;
}): UseSavedSearch => { }) => {
const { data, filterManager } = services; const { data, filterManager } = services;
const timefilter = data.query.timefilter.timefilter; const timefilter = data.query.timefilter.timefilter;
const inspectorAdapters = useMemo(() => ({ requests: new RequestAdapter() }), []);
/** /**
* The observable the UI (aka React component) subscribes to get notified about * The observables the UI (aka React component) subscribes to get notified about
* the changes in the data fetching process (high level: fetching started, data was received) * the changes in the data fetching process (high level: fetching started, data was received)
*/ */
const data$: SavedSearchDataSubject = useSingleton( const main$: DataMain$ = useBehaviorSubject({ fetchStatus: initialFetchStatus });
() =>
new BehaviorSubject<SavedSearchDataMessage>({ const documents$: DataDocuments$ = useBehaviorSubject({ fetchStatus: initialFetchStatus });
state: initialFetchStatus,
}) const totalHits$: DataTotalHits$ = useBehaviorSubject({ fetchStatus: initialFetchStatus });
);
const charts$: DataCharts$ = useBehaviorSubject({ fetchStatus: initialFetchStatus });
const dataSubjects = useMemo(() => {
return {
main$,
documents$,
totalHits$,
charts$,
};
}, [main$, charts$, documents$, totalHits$]);
/** /**
* The observable to trigger data fetching in UI * The observable to trigger data fetching in UI
* By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields * By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields
* to be processed correctly * to be processed correctly
*/ */
const refetch$ = useSingleton(() => new Subject<SavedSearchRefetchMsg>()); const refetch$ = useSingleton(() => new Subject<DataRefetchMsg>());
/** /**
* Values that shouldn't trigger re-rendering when changed * Values that shouldn't trigger re-rendering when changed
*/ */
const refs = useRef<{ const refs = useRef<{
abortController?: AbortController; abortController?: AbortController;
/** }>({});
* handler emitted by `timefilter.getAutoRefreshFetch$()`
* to notify when data completed loading and to start a new autorefresh loop
*/
autoRefreshDoneCb?: AutoRefreshDoneFn;
/**
* Number of fetches used for functional testing
*/
fetchCounter: number;
/**
* needed to right auto refresh behavior, a new auto refresh shouldnt be triggered when
* loading is still ongoing
*/
fetchStatus: FetchStatus;
/**
* needed for merging new with old field counts, high likely legacy, but kept this behavior
* because not 100% sure in this case
*/
fieldCounts: Record<string, number>;
}>({
fetchCounter: 0,
fieldCounts: {},
fetchStatus: initialFetchStatus,
});
/**
* Resets the fieldCounts cache and sends a reset message
* It is set to initial state (no documents, fetchCounter to 0)
* Needed when index pattern is switched or a new runtime field is added
*/
const sendResetMsg = useCallback(
(fetchStatus?: FetchStatus) => {
refs.current.fieldCounts = {};
refs.current.fetchStatus = fetchStatus ?? initialFetchStatus;
data$.next({
state: initialFetchStatus,
fetchCounter: 0,
rows: [],
fieldCounts: {},
chartData: undefined,
bucketInterval: undefined,
});
},
[data$, initialFetchStatus]
);
/**
* Function to fetch data from ElasticSearch
*/
const fetchAll = useCallback(
(reset = false) => {
if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) {
return Promise.reject();
}
const inspectorAdapters = { requests: new RequestAdapter() };
if (refs.current.abortController) refs.current.abortController.abort();
refs.current.abortController = new AbortController();
const sessionId = searchSessionManager.getNextSearchSessionId();
if (reset) {
sendResetMsg(FetchStatus.LOADING);
} else {
// Let the UI know, data fetching started
data$.next({
state: FetchStatus.LOADING,
fetchCounter: ++refs.current.fetchCounter,
});
refs.current.fetchStatus = FetchStatus.LOADING;
}
const { sort, hideChart, interval } = stateContainer.appStateContainer.getState();
updateSearchSource(searchSource, false, {
indexPattern,
services,
sort: sort as SortOrder[],
useNewFieldsApi,
});
const chartAggConfigs =
indexPattern.timeFieldName && !hideChart && interval
? getChartAggConfigs(searchSource, interval, data)
: undefined;
if (!chartAggConfigs) {
searchSource.removeField('aggs');
} else {
searchSource.setField('aggs', chartAggConfigs.toDsl());
}
const searchSourceFetch$ = searchSource.fetch$({
abortSignal: refs.current.abortController.signal,
sessionId,
inspector: {
adapter: inspectorAdapters.requests,
title: i18n.translate('discover.inspectorRequestDataTitle', {
defaultMessage: 'data',
}),
description: i18n.translate('discover.inspectorRequestDescriptionDocument', {
defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.',
}),
},
});
searchSourceFetch$.pipe(filter((res) => isCompleteResponse(res))).subscribe(
(res) => {
const documents = res.rawResponse.hits.hits;
const message: SavedSearchDataMessage = {
state: FetchStatus.COMPLETE,
rows: documents,
inspectorAdapters,
fieldCounts: calcFieldCounts(refs.current.fieldCounts, documents, indexPattern),
hits: res.rawResponse.hits.total as number,
};
if (chartAggConfigs) {
const bucketAggConfig = chartAggConfigs.aggs[1];
const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse);
const dimensions = getDimensions(chartAggConfigs, data);
if (dimensions) {
if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) {
message.bucketInterval = bucketAggConfig.buckets?.getInterval();
}
message.chartData = buildPointSeriesData(tabifiedData, dimensions);
}
}
refs.current.fieldCounts = message.fieldCounts!;
refs.current.fetchStatus = message.state;
data$.next(message);
},
(error) => {
if (error instanceof Error && error.name === 'AbortError') return;
data.search.showError(error);
refs.current.fetchStatus = FetchStatus.ERROR;
data$.next({
state: FetchStatus.ERROR,
inspectorAdapters,
fetchError: error,
});
},
() => {
refs.current.autoRefreshDoneCb?.();
refs.current.autoRefreshDoneCb = undefined;
}
);
},
[
timefilter,
services,
searchSessionManager,
stateContainer.appStateContainer,
searchSource,
indexPattern,
useNewFieldsApi,
data,
sendResetMsg,
data$,
]
);
/** /**
* This part takes care of triggering the data fetching by creating and subscribing * This part takes care of triggering the data fetching by creating and subscribing
* to an observable of various possible changes in state * to an observable of various possible changes in state
*/ */
useEffect(() => { useEffect(() => {
/**
* handler emitted by `timefilter.getAutoRefreshFetch$()`
* to notify when data completed loading and to start a new autorefresh loop
*/
let autoRefreshDoneCb: AutoRefreshDoneFn | undefined;
const fetch$ = merge( const fetch$ = merge(
refetch$, refetch$,
filterManager.getFetches$(), filterManager.getFetches$(),
timefilter.getFetch$(), timefilter.getFetch$(),
timefilter.getAutoRefreshFetch$().pipe( timefilter.getAutoRefreshFetch$().pipe(
tap((done) => { tap((done) => {
refs.current.autoRefreshDoneCb = done; autoRefreshDoneCb = done;
}), }),
filter(() => refs.current.fetchStatus !== FetchStatus.LOADING) filter(() => {
/**
* filter to prevent auto-refresh triggered fetch when
* loading is still ongoing
*/
const currentFetchStatus = main$.getValue().fetchStatus;
return (
currentFetchStatus !== FetchStatus.LOADING && currentFetchStatus !== FetchStatus.PARTIAL
);
})
), ),
data.query.queryString.getUpdates$(), data.query.queryString.getUpdates$(),
searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId))
).pipe(debounceTime(100)); ).pipe(debounceTime(100));
const subscription = fetch$.subscribe((val) => { const subscription = fetch$.subscribe((val) => {
if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) {
return;
}
inspectorAdapters.requests.reset();
refs.current.abortController?.abort();
refs.current.abortController = new AbortController();
try { try {
fetchAll(val === 'reset'); fetchAll(dataSubjects, searchSource, val === 'reset', {
abortController: refs.current.abortController,
appStateContainer: stateContainer.appStateContainer,
inspectorAdapters,
data,
initialFetchStatus,
searchSessionId: searchSessionManager.getNextSearchSessionId(),
services,
useNewFieldsApi,
}).subscribe({
complete: () => {
// if this function was set and is executed, another refresh fetch can be triggered
autoRefreshDoneCb?.();
autoRefreshDoneCb = undefined;
},
});
} catch (error) { } catch (error) {
data$.next({ main$.next({
state: FetchStatus.ERROR, fetchStatus: FetchStatus.ERROR,
fetchError: error, error,
}); });
} }
}); });
return () => { return () => subscription.unsubscribe();
subscription.unsubscribe();
};
}, [ }, [
data$, data,
data.query.queryString, data.query.queryString,
dataSubjects,
filterManager, filterManager,
initialFetchStatus,
inspectorAdapters,
main$,
refetch$, refetch$,
searchSessionManager,
searchSessionManager.newSearchSessionIdFromURL$, searchSessionManager.newSearchSessionIdFromURL$,
searchSource,
services,
services.toastNotifications,
stateContainer.appStateContainer,
timefilter, timefilter,
fetchAll, useNewFieldsApi,
]);
const reset = useCallback(() => sendResetMsg(dataSubjects, initialFetchStatus), [
dataSubjects,
initialFetchStatus,
]); ]);
return { return {
refetch$, refetch$,
data$, data$: dataSubjects,
reset: sendResetMsg, reset,
inspectorAdapters,
}; };
}; };

View file

@ -0,0 +1,90 @@
/*
* 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 { FetchStatus } from '../../../types';
import {
DataCharts$,
DataDocuments$,
DataMain$,
DataTotalHits$,
SavedSearchData,
} from './use_saved_search';
/**
* Send COMPLETE message via main observable used when
* 1.) first fetch resolved, and there are no documents
* 2.) all fetches resolved, and there are documents
*/
export function sendCompleteMsg(main$: DataMain$, foundDocuments = true) {
if (main$.getValue().fetchStatus === FetchStatus.COMPLETE) {
return;
}
main$.next({
fetchStatus: FetchStatus.COMPLETE,
foundDocuments,
});
}
/**
* Send PARTIAL message via main observable when first result is returned
*/
export function sendPartialMsg(main$: DataMain$) {
if (main$.getValue().fetchStatus === FetchStatus.LOADING) {
main$.next({
fetchStatus: FetchStatus.PARTIAL,
});
}
}
/**
* Send LOADING message via main observable
*/
export function sendLoadingMsg(data$: DataMain$ | DataDocuments$ | DataTotalHits$ | DataCharts$) {
if (data$.getValue().fetchStatus !== FetchStatus.LOADING) {
data$.next({
fetchStatus: FetchStatus.LOADING,
});
}
}
/**
* Send ERROR message
*/
export function sendErrorMsg(
data$: DataMain$ | DataDocuments$ | DataTotalHits$ | DataCharts$,
error: Error
) {
data$.next({
fetchStatus: FetchStatus.ERROR,
error,
});
}
/**
* Sends a RESET message to all data subjects
* Needed when index pattern is switched or a new runtime field is added
*/
export function sendResetMsg(data: SavedSearchData, initialFetchStatus: FetchStatus) {
data.main$.next({
fetchStatus: initialFetchStatus,
foundDocuments: undefined,
});
data.documents$.next({
fetchStatus: initialFetchStatus,
result: [],
});
data.charts$.next({
fetchStatus: initialFetchStatus,
chartData: undefined,
bucketInterval: undefined,
});
data.totalHits$.next({
fetchStatus: initialFetchStatus,
result: undefined,
});
}

View file

@ -15,9 +15,12 @@ import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
*/ */
export function calcFieldCounts( export function calcFieldCounts(
counts = {} as Record<string, number>, counts = {} as Record<string, number>,
rows: ElasticSearchHit[], rows?: ElasticSearchHit[],
indexPattern: IndexPattern indexPattern?: IndexPattern
) { ) {
if (!rows || !indexPattern) {
return {};
}
for (const hit of rows) { for (const hit of rows) {
const fields = Object.keys(indexPattern.flattenHit(hit)); const fields = Object.keys(indexPattern.flattenHit(hit));
for (const fieldName of fields) { for (const fieldName of fields) {

View file

@ -0,0 +1,59 @@
/*
* 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 { FetchStatus } from '../../../types';
import { BehaviorSubject } from 'rxjs';
import { RequestAdapter } from '../../../../../../inspector';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common';
import { AppState } from '../services/discover_state';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { fetchAll } from './fetch_all';
describe('test fetchAll', () => {
test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => {
const subjects = {
main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
};
const deps = {
appStateContainer: {
getState: () => {
return { interval: 'auto' };
},
} as ReduxLikeStateContainer<AppState>,
abortController: new AbortController(),
data: discoverServiceMock.data,
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
initialFetchStatus: FetchStatus.UNINITIALIZED,
useNewFieldsApi: true,
services: discoverServiceMock,
};
const stateArr: FetchStatus[] = [];
subjects.main$.subscribe((value) => stateArr.push(value.fetchStatus));
const parentSearchSource = savedSearchMock.searchSource;
const childSearchSource = parentSearchSource.createChild();
fetchAll(subjects, childSearchSource, false, deps).subscribe({
complete: () => {
expect(stateArr).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.COMPLETE,
]);
done();
},
});
});
});

View file

@ -0,0 +1,96 @@
/*
* 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 { forkJoin, of } from 'rxjs';
import {
sendCompleteMsg,
sendErrorMsg,
sendLoadingMsg,
sendPartialMsg,
sendResetMsg,
} from '../services/use_saved_search_messages';
import { updateSearchSource } from './update_search_source';
import { SortOrder } from '../../../../saved_searches/types';
import { fetchDocuments } from './fetch_documents';
import { fetchTotalHits } from './fetch_total_hits';
import { fetchChart } from './fetch_chart';
import { SearchSource } from '../../../../../../data/common';
import { Adapters } from '../../../../../../inspector';
import { AppState } from '../services/discover_state';
import { FetchStatus } from '../../../types';
import { DataPublicPluginStart } from '../../../../../../data/public';
import { SavedSearchData } from '../services/use_saved_search';
import { DiscoverServices } from '../../../../build_services';
import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common';
export function fetchAll(
dataSubjects: SavedSearchData,
searchSource: SearchSource,
reset = false,
fetchDeps: {
abortController: AbortController;
appStateContainer: ReduxLikeStateContainer<AppState>;
inspectorAdapters: Adapters;
data: DataPublicPluginStart;
initialFetchStatus: FetchStatus;
searchSessionId: string;
services: DiscoverServices;
useNewFieldsApi: boolean;
}
) {
const { initialFetchStatus, appStateContainer, services, useNewFieldsApi, data } = fetchDeps;
const indexPattern = searchSource.getField('index')!;
if (reset) {
sendResetMsg(dataSubjects, initialFetchStatus);
}
sendLoadingMsg(dataSubjects.main$);
const { hideChart, sort } = appStateContainer.getState();
// Update the base searchSource, base for all child fetches
updateSearchSource(searchSource, false, {
indexPattern,
services,
sort: sort as SortOrder[],
useNewFieldsApi,
});
const subFetchDeps = {
...fetchDeps,
onResults: (foundDocuments: boolean) => {
if (!foundDocuments) {
sendCompleteMsg(dataSubjects.main$, foundDocuments);
} else {
sendPartialMsg(dataSubjects.main$);
}
},
};
const all = forkJoin({
documents: fetchDocuments(dataSubjects, searchSource.createCopy(), subFetchDeps),
totalHits:
hideChart || !indexPattern.timeFieldName
? fetchTotalHits(dataSubjects, searchSource.createCopy(), subFetchDeps)
: of(null),
chart:
!hideChart && indexPattern.timeFieldName
? fetchChart(dataSubjects, searchSource.createCopy(), subFetchDeps)
: of(null),
});
all.subscribe(
() => sendCompleteMsg(dataSubjects.main$, true),
(error) => {
if (error instanceof Error && error.name === 'AbortError') return;
data.search.showError(error);
sendErrorMsg(dataSubjects.main$, error);
}
);
return all;
}

View file

@ -0,0 +1,174 @@
/*
* 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 { FetchStatus } from '../../../types';
import { BehaviorSubject, of, throwError as throwErrorRx } from 'rxjs';
import { RequestAdapter } from '../../../../../../inspector';
import { savedSearchMockWithTimeField } from '../../../../__mocks__/saved_search';
import { fetchChart, updateSearchSource } from './fetch_chart';
import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common';
import { AppState } from '../services/discover_state';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { calculateBounds, IKibanaSearchResponse } from '../../../../../../data/common';
import { estypes } from '@elastic/elasticsearch';
function getDataSubjects() {
return {
main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
};
}
describe('test fetchCharts', () => {
test('updateSearchSource helper function', () => {
const chartAggConfigs = updateSearchSource(
savedSearchMockWithTimeField.searchSource,
'auto',
discoverServiceMock.data
);
expect(chartAggConfigs.aggs).toMatchInlineSnapshot(`
Array [
Object {
"enabled": true,
"id": "1",
"params": Object {},
"schema": "metric",
"type": "count",
},
Object {
"enabled": true,
"id": "2",
"params": Object {
"drop_partials": false,
"extended_bounds": Object {},
"field": "timestamp",
"interval": "auto",
"min_doc_count": 1,
"scaleMetricValues": false,
"useNormalizedEsInterval": true,
"used_interval": "0ms",
},
"schema": "segment",
"type": "date_histogram",
},
]
`);
});
test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => {
const subjects = getDataSubjects();
const deps = {
appStateContainer: {
getState: () => {
return { interval: 'auto' };
},
} as ReduxLikeStateContainer<AppState>,
abortController: new AbortController(),
data: discoverServiceMock.data,
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
};
deps.data.query.timefilter.timefilter.getTime = () => {
return { from: '2021-07-07T00:05:13.590', to: '2021-07-07T11:20:13.590' };
};
deps.data.query.timefilter.timefilter.calculateBounds = (timeRange) =>
calculateBounds(timeRange);
const stateArrChart: FetchStatus[] = [];
const stateArrHits: FetchStatus[] = [];
subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus));
subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus));
savedSearchMockWithTimeField.searchSource.fetch$ = () =>
of(({
id: 'Fjk5bndxTHJWU2FldVRVQ0tYR0VqOFEcRWtWNDhOdG5SUzJYcFhONVVZVTBJQToxMDMwOQ==',
rawResponse: {
took: 2,
timed_out: false,
_shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
hits: { max_score: null, hits: [] },
aggregations: {
'2': {
buckets: [
{
key_as_string: '2021-07-07T06:36:00.000+02:00',
key: 1625632560000,
doc_count: 1,
},
],
},
},
},
isPartial: false,
isRunning: false,
total: 1,
loaded: 1,
isRestored: false,
} as unknown) as IKibanaSearchResponse<estypes.SearchResponse<unknown>>);
fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({
complete: () => {
expect(stateArrChart).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.COMPLETE,
]);
expect(stateArrHits).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.COMPLETE,
]);
done();
},
});
});
test('change of fetchStatus on fetch error', async (done) => {
const subjects = getDataSubjects();
const deps = {
appStateContainer: {
getState: () => {
return { interval: 'auto' };
},
} as ReduxLikeStateContainer<AppState>,
abortController: new AbortController(),
data: discoverServiceMock.data,
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
};
savedSearchMockWithTimeField.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' });
const stateArrChart: FetchStatus[] = [];
const stateArrHits: FetchStatus[] = [];
subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus));
subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus));
fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({
error: () => {
expect(stateArrChart).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.ERROR,
]);
expect(stateArrHits).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.ERROR,
]);
done();
},
});
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { filter } from 'rxjs/operators';
import {
DataPublicPluginStart,
isCompleteResponse,
search,
SearchSource,
} from '../../../../../../data/public';
import { Adapters } from '../../../../../../inspector';
import { getChartAggConfigs, getDimensions } from './index';
import { tabifyAggResponse } from '../../../../../../data/common';
import { buildPointSeriesData } from '../components/chart/point_series';
import { FetchStatus } from '../../../types';
import { SavedSearchData } from '../services/use_saved_search';
import { AppState } from '../services/discover_state';
import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common';
import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages';
export function fetchChart(
data$: SavedSearchData,
searchSource: SearchSource,
{
abortController,
appStateContainer,
data,
inspectorAdapters,
onResults,
searchSessionId,
}: {
abortController: AbortController;
appStateContainer: ReduxLikeStateContainer<AppState>;
data: DataPublicPluginStart;
inspectorAdapters: Adapters;
onResults: (foundDocuments: boolean) => void;
searchSessionId: string;
}
) {
const { charts$, totalHits$ } = data$;
const interval = appStateContainer.getState().interval ?? 'auto';
const chartAggConfigs = updateSearchSource(searchSource, interval, data);
sendLoadingMsg(charts$);
sendLoadingMsg(totalHits$);
const fetch$ = searchSource
.fetch$({
abortSignal: abortController.signal,
sessionId: searchSessionId,
inspector: {
adapter: inspectorAdapters.requests,
title: i18n.translate('discover.inspectorRequestDataTitleChart', {
defaultMessage: 'Chart data',
}),
description: i18n.translate('discover.inspectorRequestDescriptionChart', {
defaultMessage:
'This request queries Elasticsearch to fetch the aggregation data for the chart.',
}),
},
})
.pipe(filter((res) => isCompleteResponse(res)));
fetch$.subscribe(
(res) => {
try {
const totalHitsNr = res.rawResponse.hits.total as number;
totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr });
onResults(totalHitsNr > 0);
const bucketAggConfig = chartAggConfigs.aggs[1];
const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse);
const dimensions = getDimensions(chartAggConfigs, data);
const bucketInterval = search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)
? bucketAggConfig?.buckets?.getInterval()
: undefined;
const chartData = buildPointSeriesData(tabifiedData, dimensions!);
charts$.next({
fetchStatus: FetchStatus.COMPLETE,
chartData,
bucketInterval,
});
} catch (e) {
charts$.next({
fetchStatus: FetchStatus.ERROR,
error: e,
});
}
},
(error) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
sendErrorMsg(charts$, error);
sendErrorMsg(totalHits$, error);
}
);
return fetch$;
}
export function updateSearchSource(
searchSource: SearchSource,
interval: string,
data: DataPublicPluginStart
) {
const indexPattern = searchSource.getField('index')!;
searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(indexPattern));
searchSource.setField('size', 0);
searchSource.setField('trackTotalHits', true);
const chartAggConfigs = getChartAggConfigs(searchSource, interval, data);
searchSource.setField('aggs', chartAggConfigs.toDsl());
searchSource.removeField('sort');
searchSource.removeField('fields');
return chartAggConfigs;
}

View file

@ -0,0 +1,79 @@
/*
* 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 { fetchDocuments } from './fetch_documents';
import { FetchStatus } from '../../../types';
import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs';
import { RequestAdapter } from '../../../../../../inspector';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import { discoverServiceMock } from '../../../../__mocks__/services';
function getDataSubjects() {
return {
main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
};
}
describe('test fetchDocuments', () => {
test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => {
const subjects = getDataSubjects();
const { documents$ } = subjects;
const deps = {
abortController: new AbortController(),
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
services: discoverServiceMock,
};
const stateArr: FetchStatus[] = [];
documents$.subscribe((value) => stateArr.push(value.fetchStatus));
fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({
complete: () => {
expect(stateArr).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.COMPLETE,
]);
done();
},
});
});
test('change of fetchStatus on fetch error', async (done) => {
const subjects = getDataSubjects();
const { documents$ } = subjects;
const deps = {
abortController: new AbortController(),
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
services: discoverServiceMock,
};
savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' });
const stateArr: FetchStatus[] = [];
documents$.subscribe((value) => stateArr.push(value.fetchStatus));
fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({
error: () => {
expect(stateArr).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.ERROR,
]);
done();
},
});
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { filter } from 'rxjs/operators';
import { Adapters } from '../../../../../../inspector/common';
import { isCompleteResponse, SearchSource } from '../../../../../../data/common';
import { FetchStatus } from '../../../types';
import { SavedSearchData } from '../services/use_saved_search';
import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages';
import { SAMPLE_SIZE_SETTING } from '../../../../../common';
import { DiscoverServices } from '../../../../build_services';
export const fetchDocuments = (
data$: SavedSearchData,
searchSource: SearchSource,
{
abortController,
inspectorAdapters,
onResults,
searchSessionId,
services,
}: {
abortController: AbortController;
inspectorAdapters: Adapters;
onResults: (foundDocuments: boolean) => void;
searchSessionId: string;
services: DiscoverServices;
}
) => {
const { documents$, totalHits$ } = data$;
searchSource.setField('size', services.uiSettings.get(SAMPLE_SIZE_SETTING));
searchSource.setField('trackTotalHits', false);
searchSource.setField('highlightAll', true);
searchSource.setField('version', true);
sendLoadingMsg(documents$);
const fetch$ = searchSource
.fetch$({
abortSignal: abortController.signal,
sessionId: searchSessionId,
inspector: {
adapter: inspectorAdapters.requests,
title: i18n.translate('discover.inspectorRequestDataTitleDocuments', {
defaultMessage: 'Documents',
}),
description: i18n.translate('discover.inspectorRequestDescriptionDocument', {
defaultMessage: 'This request queries Elasticsearch to fetch the documents.',
}),
},
})
.pipe(filter((res) => isCompleteResponse(res)));
fetch$.subscribe(
(res) => {
const documents = res.rawResponse.hits.hits;
// If the total hits query is still loading for hits, emit a partial
// hit count that's at least our document count
if (totalHits$.getValue().fetchStatus === FetchStatus.LOADING) {
totalHits$.next({
fetchStatus: FetchStatus.PARTIAL,
result: documents.length,
});
}
documents$.next({
fetchStatus: FetchStatus.COMPLETE,
result: documents,
});
onResults(documents.length > 0);
},
(error) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
sendErrorMsg(documents$, error);
}
);
return fetch$;
};

View file

@ -0,0 +1,80 @@
/*
* 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 { FetchStatus } from '../../../types';
import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs';
import { RequestAdapter } from '../../../../../../inspector';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import { fetchTotalHits } from './fetch_total_hits';
import { discoverServiceMock } from '../../../../__mocks__/services';
function getDataSubjects() {
return {
main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }),
};
}
describe('test fetchTotalHits', () => {
test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => {
const subjects = getDataSubjects();
const { totalHits$ } = subjects;
const deps = {
abortController: new AbortController(),
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
data: discoverServiceMock.data,
};
const stateArr: FetchStatus[] = [];
totalHits$.subscribe((value) => stateArr.push(value.fetchStatus));
fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({
complete: () => {
expect(stateArr).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.COMPLETE,
]);
done();
},
});
});
test('change of fetchStatus on fetch error', async (done) => {
const subjects = getDataSubjects();
const { totalHits$ } = subjects;
const deps = {
abortController: new AbortController(),
inspectorAdapters: { requests: new RequestAdapter() },
onResults: jest.fn(),
searchSessionId: '123',
data: discoverServiceMock.data,
};
savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' });
const stateArr: FetchStatus[] = [];
totalHits$.subscribe((value) => stateArr.push(value.fetchStatus));
fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({
error: () => {
expect(stateArr).toEqual([
FetchStatus.UNINITIALIZED,
FetchStatus.LOADING,
FetchStatus.ERROR,
]);
done();
},
});
});
});

View file

@ -0,0 +1,80 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { filter } from 'rxjs/operators';
import {
DataPublicPluginStart,
isCompleteResponse,
SearchSource,
} from '../../../../../../data/public';
import { Adapters } from '../../../../../../inspector/common';
import { FetchStatus } from '../../../types';
import { SavedSearchData } from '../services/use_saved_search';
import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages';
export function fetchTotalHits(
data$: SavedSearchData,
searchSource: SearchSource,
{
abortController,
data,
inspectorAdapters,
onResults,
searchSessionId,
}: {
abortController: AbortController;
data: DataPublicPluginStart;
onResults: (foundDocuments: boolean) => void;
inspectorAdapters: Adapters;
searchSessionId: string;
}
) {
const { totalHits$ } = data$;
const indexPattern = searchSource.getField('index');
searchSource.setField('trackTotalHits', true);
searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(indexPattern!));
searchSource.setField('size', 0);
searchSource.removeField('sort');
searchSource.removeField('fields');
searchSource.removeField('aggs');
sendLoadingMsg(totalHits$);
const fetch$ = searchSource
.fetch$({
inspector: {
adapter: inspectorAdapters.requests,
title: i18n.translate('discover.inspectorRequestDataTitleTotalHits', {
defaultMessage: 'Total hits',
}),
description: i18n.translate('discover.inspectorRequestDescriptionTotalHits', {
defaultMessage: 'This request queries Elasticsearch to fetch the total hits.',
}),
},
abortSignal: abortController.signal,
sessionId: searchSessionId,
})
.pipe(filter((res) => isCompleteResponse(res)));
fetch$.subscribe(
(res) => {
const totalHitsNr = res.rawResponse.hits.total as number;
totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr });
onResults(totalHitsNr > 0);
},
(error) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
sendErrorMsg(totalHits$, error);
}
);
return fetch$;
}

View file

@ -6,40 +6,36 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { getResultState, resultStatuses } from './get_result_state'; import { getResultState, resultStatuses } from './get_result_state';
import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../types'; import { FetchStatus } from '../../../types';
describe('getResultState', () => { describe('getResultState', () => {
test('fetching uninitialized', () => { test('fetching uninitialized', () => {
const actual = getResultState(FetchStatus.UNINITIALIZED, []); const actual = getResultState(FetchStatus.UNINITIALIZED, false);
expect(actual).toBe(resultStatuses.UNINITIALIZED); expect(actual).toBe(resultStatuses.UNINITIALIZED);
}); });
test('fetching complete with no records', () => { test('fetching complete with no records', () => {
const actual = getResultState(FetchStatus.COMPLETE, []); const actual = getResultState(FetchStatus.COMPLETE, false);
expect(actual).toBe(resultStatuses.NO_RESULTS); expect(actual).toBe(resultStatuses.NO_RESULTS);
}); });
test('fetching ongoing aka loading', () => { test('fetching ongoing aka loading', () => {
const actual = getResultState(FetchStatus.LOADING, []); const actual = getResultState(FetchStatus.LOADING, false);
expect(actual).toBe(resultStatuses.LOADING); expect(actual).toBe(resultStatuses.LOADING);
}); });
test('fetching ready', () => { test('fetching ready', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit; const actual = getResultState(FetchStatus.COMPLETE, true);
const actual = getResultState(FetchStatus.COMPLETE, [record]);
expect(actual).toBe(resultStatuses.READY); expect(actual).toBe(resultStatuses.READY);
}); });
test('re-fetching after already data is available', () => { test('re-fetching after already data is available', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit; const actual = getResultState(FetchStatus.LOADING, true);
const actual = getResultState(FetchStatus.LOADING, [record]);
expect(actual).toBe(resultStatuses.READY); expect(actual).toBe(resultStatuses.READY);
}); });
test('after a fetch error when data was successfully fetched before ', () => { test('after a fetch error when data was successfully fetched before ', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit; const actual = getResultState(FetchStatus.ERROR, true);
const actual = getResultState(FetchStatus.ERROR, [record]);
expect(actual).toBe(resultStatuses.READY); expect(actual).toBe(resultStatuses.READY);
}); });
}); });

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../types'; import { FetchStatus } from '../../../types';
export const resultStatuses = { export const resultStatuses = {
@ -19,13 +18,13 @@ export const resultStatuses = {
* Returns the current state of the result, depends on fetchStatus and the given fetched rows * Returns the current state of the result, depends on fetchStatus and the given fetched rows
* Determines what is displayed in Discover main view (loading view, data view, empty data view, ...) * Determines what is displayed in Discover main view (loading view, data view, empty data view, ...)
*/ */
export function getResultState(fetchStatus: FetchStatus, rows: ElasticSearchHit[]) { export function getResultState(fetchStatus: FetchStatus, foundDocuments: boolean = false) {
if (fetchStatus === FetchStatus.UNINITIALIZED) { if (fetchStatus === FetchStatus.UNINITIALIZED) {
return resultStatuses.UNINITIALIZED; return resultStatuses.UNINITIALIZED;
} }
const rowsEmpty = !Array.isArray(rows) || rows.length === 0; if (!foundDocuments && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING;
if (rowsEmpty && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING; else if (foundDocuments) return resultStatuses.READY;
else if (!rowsEmpty) return resultStatuses.READY; else if (fetchStatus === FetchStatus.PARTIAL) return resultStatuses.READY;
else return resultStatuses.NO_RESULTS; else return resultStatuses.NO_RESULTS;
} }

View file

@ -9,39 +9,21 @@
import { updateSearchSource } from './update_search_source'; import { updateSearchSource } from './update_search_source';
import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks'; import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks';
import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { indexPatternMock } from '../../../../__mocks__/index_pattern';
import { IUiSettingsClient } from 'kibana/public';
import { DiscoverServices } from '../../../../build_services';
import { dataPluginMock } from '../../../../../../data/public/mocks';
import { SAMPLE_SIZE_SETTING } from '../../../../../common';
import { SortOrder } from '../../../../saved_searches/types'; import { SortOrder } from '../../../../saved_searches/types';
import { discoverServiceMock } from '../../../../__mocks__/services';
describe('updateSearchSource', () => { describe('updateSearchSource', () => {
test('updates a given search source', async () => { test('updates a given search source', async () => {
const persistentSearchSourceMock = createSearchSourceMock({}); const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock); volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, { updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock, indexPattern: indexPatternMock,
services: ({ services: discoverServiceMock,
data: dataPluginMock.createStartContract(),
timefilter: {
createFilter: jest.fn(),
},
uiSettings: ({
get: (key: string) => {
if (key === SAMPLE_SIZE_SETTING) {
return sampleSize;
}
return false;
},
} as unknown) as IUiSettingsClient,
} as unknown) as DiscoverServices,
sort: [] as SortOrder[], sort: [] as SortOrder[],
useNewFieldsApi: false, useNewFieldsApi: false,
}); });
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toBe(undefined); expect(volatileSearchSourceMock.getField('fields')).toBe(undefined);
}); });
@ -49,28 +31,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({}); const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock); volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, { updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock, indexPattern: indexPatternMock,
services: ({ services: discoverServiceMock,
data: dataPluginMock.createStartContract(),
timefilter: {
createFilter: jest.fn(),
},
uiSettings: ({
get: (key: string) => {
if (key === SAMPLE_SIZE_SETTING) {
return sampleSize;
}
return false;
},
} as unknown) as IUiSettingsClient,
} as unknown) as DiscoverServices,
sort: [] as SortOrder[], sort: [] as SortOrder[],
useNewFieldsApi: true, useNewFieldsApi: true,
}); });
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual([ expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' }, { field: '*', include_unmapped: 'true' },
]); ]);
@ -81,28 +48,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({}); const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock); volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, { updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock, indexPattern: indexPatternMock,
services: ({ services: discoverServiceMock,
data: dataPluginMock.createStartContract(),
timefilter: {
createFilter: jest.fn(),
},
uiSettings: ({
get: (key: string) => {
if (key === SAMPLE_SIZE_SETTING) {
return sampleSize;
}
return false;
},
} as unknown) as IUiSettingsClient,
} as unknown) as DiscoverServices,
sort: [] as SortOrder[], sort: [] as SortOrder[],
useNewFieldsApi: true, useNewFieldsApi: true,
}); });
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual([ expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' }, { field: '*', include_unmapped: 'true' },
]); ]);
@ -113,28 +65,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({}); const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock); volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, { updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock, indexPattern: indexPatternMock,
services: ({ services: discoverServiceMock,
data: dataPluginMock.createStartContract(),
timefilter: {
createFilter: jest.fn(),
},
uiSettings: ({
get: (key: string) => {
if (key === SAMPLE_SIZE_SETTING) {
return sampleSize;
}
return false;
},
} as unknown) as IUiSettingsClient,
} as unknown) as DiscoverServices,
sort: [] as SortOrder[], sort: [] as SortOrder[],
useNewFieldsApi: false, useNewFieldsApi: false,
}); });
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined); expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined);
expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined);
}); });

View file

@ -7,7 +7,7 @@
*/ */
import { getSortForSearchSource } from '../../../angular/doc_table'; import { getSortForSearchSource } from '../../../angular/doc_table';
import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import { SORT_DEFAULT_ORDER_SETTING } from '../../../../../common';
import { IndexPattern, ISearchSource } from '../../../../../../data/common'; import { IndexPattern, ISearchSource } from '../../../../../../data/common';
import { SortOrder } from '../../../../saved_searches/types'; import { SortOrder } from '../../../../saved_searches/types';
import { DiscoverServices } from '../../../../build_services'; import { DiscoverServices } from '../../../../build_services';
@ -32,25 +32,22 @@ export function updateSearchSource(
} }
) { ) {
const { uiSettings, data } = services; const { uiSettings, data } = services;
const usedSort = getSortForSearchSource( const parentSearchSource = persist ? searchSource : searchSource.getParent()!;
sort,
indexPattern,
uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
);
const usedSearchSource = persist ? searchSource : searchSource.getParent()!;
usedSearchSource parentSearchSource
.setField('index', indexPattern) .setField('index', indexPattern)
.setField('query', data.query.queryString.getQuery() || null) .setField('query', data.query.queryString.getQuery() || null)
.setField('filter', data.query.filterManager.getFilters()); .setField('filter', data.query.filterManager.getFilters());
if (!persist) { if (!persist) {
const usedSort = getSortForSearchSource(
sort,
indexPattern,
uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
);
searchSource searchSource
.setField('trackTotalHits', true) .setField('trackTotalHits', true)
.setField('size', uiSettings.get(SAMPLE_SIZE_SETTING))
.setField('sort', usedSort) .setField('sort', usedSort)
.setField('highlightAll', true)
.setField('version', true)
// Even when searching rollups, we want to use the default strategy so that we get back a // Even when searching rollups, we want to use the default strategy so that we get back a
// document-like response. // document-like response.
.setPreferredSearchStrategyId('default'); .setPreferredSearchStrategyId('default');

View file

@ -5,12 +5,15 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { useRef } from 'react';
import { BehaviorSubject } from 'rxjs';
const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g; export function useBehaviorSubject<T>(props: T): BehaviorSubject<T> {
const ref = useRef<BehaviorSubject<T> | null>(null);
/** if (ref.current === null) {
* Converts a number to a string and adds commas ref.current = new BehaviorSubject(props);
* as thousands separators }
*/
export const formatNumWithCommas = (input: number) => return ref.current;
String(input).replace(COMMA_SEPARATOR_RE, '$1,'); }

View file

@ -0,0 +1,24 @@
/*
* 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 { useState, useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import { DataMsg } from '../services/use_saved_search';
export function useDataState(data$: BehaviorSubject<DataMsg>) {
const [fetchState, setFetchState] = useState<DataMsg>(data$.getValue());
useEffect(() => {
const subscription = data$.subscribe((next) => {
if (next.fetchStatus !== fetchState.fetchStatus) {
setFetchState({ ...fetchState, ...next });
}
});
return () => subscription.unsubscribe();
}, [data$, fetchState, setFetchState]);
return fetchState;
}

View file

@ -55,6 +55,7 @@ describe('Test of <Doc /> helper / hook', () => {
}, },
"script_fields": Array [], "script_fields": Array [],
"stored_fields": Array [], "stored_fields": Array [],
"version": true,
}, },
} }
`); `);
@ -84,6 +85,7 @@ describe('Test of <Doc /> helper / hook', () => {
"runtime_mappings": Object {}, "runtime_mappings": Object {},
"script_fields": Array [], "script_fields": Array [],
"stored_fields": Array [], "stored_fields": Array [],
"version": true,
}, },
} }
`); `);
@ -114,6 +116,7 @@ describe('Test of <Doc /> helper / hook', () => {
"runtime_mappings": Object {}, "runtime_mappings": Object {},
"script_fields": Array [], "script_fields": Array [],
"stored_fields": Array [], "stored_fields": Array [],
"version": true,
}, },
} }
`); `);
@ -162,6 +165,7 @@ describe('Test of <Doc /> helper / hook', () => {
}, },
"script_fields": Array [], "script_fields": Array [],
"stored_fields": Array [], "stored_fields": Array [],
"version": true,
}, },
} }
`); `);

View file

@ -37,6 +37,7 @@ export function buildSearchBody(
}, },
stored_fields: computedFields.storedFields, stored_fields: computedFields.storedFields,
script_fields: computedFields.scriptFields, script_fields: computedFields.scriptFields,
version: true,
}, },
}; };
if (!request.body) { if (!request.body) {

View file

@ -1,9 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { formatNumWithCommas } from './format_number_with_commas';

View file

@ -9,6 +9,7 @@
export enum FetchStatus { export enum FetchStatus {
UNINITIALIZED = 'uninitialized', UNINITIALIZED = 'uninitialized',
LOADING = 'loading', LOADING = 'loading',
PARTIAL = 'partial',
COMPLETE = 'complete', COMPLETE = 'complete',
ERROR = 'error', ERROR = 'error',
} }

View file

@ -108,7 +108,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should modify the time range when the histogram is brushed', async function () { it('should modify the time range when the histogram is brushed', async function () {
// this is the number of renderings of the histogram needed when new data is fetched // this is the number of renderings of the histogram needed when new data is fetched
// this needs to be improved // this needs to be improved
const renderingCountInc = 1; const renderingCountInc = 2;
const prevRenderingCount = await elasticChart.getVisualizationRenderingCount(); const prevRenderingCount = await elasticChart.getVisualizationRenderingCount();
await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.timePicker.setDefaultAbsoluteRange();
await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished();

View file

@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver'); const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer'); const kibanaServer = getService('kibanaServer');
const inspector = getService('inspector'); const inspector = getService('inspector');
const testSubjects = getService('testSubjects');
const STATS_ROW_NAME_INDEX = 0; const STATS_ROW_NAME_INDEX = 0;
const STATS_ROW_VALUE_INDEX = 1; const STATS_ROW_VALUE_INDEX = 1;
@ -50,15 +51,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should display request stats with no results', async () => { it('should display request stats with no results', async () => {
await inspector.open(); await inspector.open();
const requestStats = await inspector.getTableData(); await testSubjects.click('inspectorRequestChooser');
let foundZero = false;
expect(getHitCount(requestStats)).to.be('0'); for (const subj of ['Documents', 'Total hits', 'Charts']) {
await testSubjects.click(`inspectorRequestChooser${subj}`);
if (testSubjects.exists('inspectorRequestDetailStatistics', { timeout: 500 })) {
await testSubjects.click(`inspectorRequestDetailStatistics`);
const requestStatsTotalHits = getHitCount(await inspector.getTableData());
if (requestStatsTotalHits === '0') {
foundZero = true;
break;
}
}
}
expect(foundZero).to.be(true);
}); });
it('should display request stats with results', async () => { it('should display request stats with results', async () => {
await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.timePicker.setDefaultAbsoluteRange();
await inspector.open(); await inspector.open();
await testSubjects.click('inspectorRequestChooser');
await testSubjects.click(`inspectorRequestChooserDocuments`);
await testSubjects.click(`inspectorRequestDetailStatistics`);
const requestStats = await inspector.getTableData(); const requestStats = await inspector.getTableData();
expect(getHitCount(requestStats)).to.be('500'); expect(getHitCount(requestStats)).to.be('500');

View file

@ -1715,7 +1715,6 @@
"discover.howToChangeTheTimeTooltip": "時刻を変更するには、グローバル時刻フィルターを使用します。", "discover.howToChangeTheTimeTooltip": "時刻を変更するには、グローバル時刻フィルターを使用します。",
"discover.howToSeeOtherMatchingDocumentsDescription": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。", "discover.howToSeeOtherMatchingDocumentsDescription": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
"discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。", "discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
"discover.inspectorRequestDataTitle": "データ",
"discover.inspectorRequestDescriptionDocument": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。", "discover.inspectorRequestDescriptionDocument": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。",
"discover.json.codeEditorAriaLabel": "Elasticsearch ドキュメントの JSON ビューのみを読み込む", "discover.json.codeEditorAriaLabel": "Elasticsearch ドキュメントの JSON ビューのみを読み込む",
"discover.json.copyToClipboardLabel": "クリップボードにコピー", "discover.json.copyToClipboardLabel": "クリップボードにコピー",

View file

@ -1721,11 +1721,9 @@
"discover.helpMenu.appName": "Discover", "discover.helpMenu.appName": "Discover",
"discover.hideChart": "隐藏图表", "discover.hideChart": "隐藏图表",
"discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", "discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图",
"discover.hitsPluralTitle": "{hits, plural, other {命中}}",
"discover.howToChangeTheTimeTooltip": "要更改时间,请使用全局时间筛选。", "discover.howToChangeTheTimeTooltip": "要更改时间,请使用全局时间筛选。",
"discover.howToSeeOtherMatchingDocumentsDescription": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。", "discover.howToSeeOtherMatchingDocumentsDescription": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。",
"discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。", "discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。",
"discover.inspectorRequestDataTitle": "数据",
"discover.inspectorRequestDescriptionDocument": "此请求将查询 Elasticsearch 以获取搜索的数据。", "discover.inspectorRequestDescriptionDocument": "此请求将查询 Elasticsearch 以获取搜索的数据。",
"discover.json.codeEditorAriaLabel": "Elasticsearch 文档的只读 JSON 视图", "discover.json.codeEditorAriaLabel": "Elasticsearch 文档的只读 JSON 视图",
"discover.json.copyToClipboardLabel": "复制到剪贴板", "discover.json.copyToClipboardLabel": "复制到剪贴板",