[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.
*/
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators';
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';
@ -105,6 +107,30 @@ describe('ThemeService', () => {
act(() => darkMode$.next(false));
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', () => {
@ -123,5 +149,29 @@ describe('ThemeService', () => {
act(() => darkMode$.next(false));
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.
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Observable, BehaviorSubject } from 'rxjs';
import { CoreSetup } from 'kibana/public';
@ -54,11 +54,18 @@ export class ThemeService {
/** A React hook for consuming the charts theme */
public useChartsTheme = (): PartialTheme => {
// 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
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();
}, []);
@ -68,11 +75,18 @@ export class ThemeService {
/** A React hook for consuming the charts theme */
public useChartsBaseTheme = (): Theme => {
// 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
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();
}, []);

View file

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

View file

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

View file

@ -9,6 +9,7 @@
import { SavedSearch } from '../saved_searches';
import { createSearchSourceMock } from '../../../data/public/mocks';
import { indexPatternMock } from './index_pattern';
import { indexPatternWithTimefieldMock } from './index_pattern_with_timefield';
export const savedSearchMock = ({
id: 'the-saved-search-id',
@ -31,3 +32,25 @@ export const savedSearchMock = ({
error: undefined,
searchSource: createSearchSourceMock({ index: indexPatternMock }),
} 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
* Side Public License, v 1.
*/
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { DiscoverServices } from '../build_services';
import { dataPluginMock } from '../../../data/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 { UI_SETTINGS } from '../../../data/common';
import { TopNavMenu } from '../../../navigation/public';
@ -44,8 +49,15 @@ export const discoverServiceMock = ({
return [];
} else if (key === UI_SETTINGS.META_FIELDS) {
return [];
} else if (key === SAMPLE_SIZE_SETTING) {
return 250;
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
return 'desc';
}
},
isDefault: (key: string) => {
return true;
},
},
indexPatternFieldEditor: {
openEditor: jest.fn(),
@ -60,4 +72,8 @@ export const discoverServiceMock = ({
metadata: {
branch: 'test',
},
theme: {
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
},
} 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
* 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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { IUiSettingsClient } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { HitsCounter } from '../hits_counter';
import { DataPublicPluginStart, IndexPattern, search } from '../../../../../../../data/public';
import { search } from '../../../../../../../data/public';
import { TimechartHeader } from '../timechart_header';
import { SavedSearch } from '../../../../../saved_searches';
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 { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search';
import { DiscoverServices } from '../../../../../build_services';
const TimechartHeaderMemoized = React.memo(TimechartHeader);
const DiscoverHistogramMemoized = React.memo(DiscoverHistogram);
const TimechartHeaderMemoized = memo(TimechartHeader);
const DiscoverHistogramMemoized = memo(DiscoverHistogram);
export function DiscoverChart({
config,
data,
bucketInterval,
chartData,
hits,
isLegacy,
resetQuery,
savedSearch,
savedSearchDataChart$,
savedSearchDataTotalHits$,
services,
state,
stateContainer,
timefield,
}: {
config: IUiSettingsClient;
data: DataPublicPluginStart;
bucketInterval?: TimechartBucketInterval;
chartData?: IChart;
hits?: number;
indexPattern: IndexPattern;
isLegacy: boolean;
resetQuery: () => void;
savedSearch: SavedSearch;
savedSearchDataChart$: DataCharts$;
savedSearchDataTotalHits$: DataTotalHits$;
services: DiscoverServices;
state: AppState;
stateContainer: GetStateReturn;
timefield?: string;
}) {
const { data, uiSettings: config } = services;
const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({
element: null,
moveFocus: false,
@ -93,7 +88,7 @@ export function DiscoverChart({
className="dscResuntCount__title eui-textTruncate eui-textNoWrap"
>
<HitsCounter
hits={hits}
savedSearchData$={savedSearchDataTotalHits$}
showResetButton={!!(savedSearch && savedSearch.id)}
onResetQuery={resetQuery}
/>
@ -106,7 +101,7 @@ export function DiscoverChart({
options={search.aggs.intervalOptions}
onChangeInterval={onChangeInterval}
stateInterval={state.interval || ''}
bucketInterval={bucketInterval}
savedSearchData$={savedSearchDataChart$}
/>
</EuiFlexItem>
)}
@ -130,7 +125,7 @@ export function DiscoverChart({
)}
</EuiFlexGroup>
</EuiFlexItem>
{!state.hideChart && chartData && (
{timefield && !state.hideChart && (
<EuiFlexItem grow={false}>
<section
ref={(element) => (chartRef.current.element = element)}
@ -145,8 +140,9 @@ export function DiscoverChart({
data-test-subj="discoverChart"
>
<DiscoverHistogramMemoized
chartData={chartData}
savedSearchData$={savedSearchDataChart$}
timefilterUpdateHandler={timefilterUpdateHandler}
services={services}
/>
</div>
</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
* Side Public License, v 1.
*/
import './histogram.scss';
import moment, { unitOfTime } from 'moment-timezone';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import { EuiLoadingChart, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
Axis,
BrushEndListener,
Chart,
ElementClickListener,
HistogramBarSeries,
Position,
ScaleType,
Settings,
TooltipType,
ElementClickListener,
XYChartElementEvent,
BrushEndListener,
Theme,
} from '@elastic/charts';
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 {
CurrentTime,
Endzones,
getAdjustedInterval,
renderEndzoneTooltip,
} 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 {
chartData: IChart;
savedSearchData$: DataCharts$;
timefilterUpdateHandler: (ranges: { from: number; to: number }) => void;
}
interface DiscoverHistogramState {
chartsTheme: EuiChartThemeType['theme'];
chartsBaseTheme: Theme;
services: DiscoverServices;
}
function getTimezone(uiSettings: IUiSettingsClient) {
@ -56,154 +52,149 @@ function getTimezone(uiSettings: IUiSettingsClient) {
}
}
export class DiscoverHistogram extends Component<DiscoverHistogramProps, DiscoverHistogramState> {
public static propTypes = {
chartData: PropTypes.object,
timefilterUpdateHandler: PropTypes.func,
};
export function DiscoverHistogram({
savedSearchData$,
timefilterUpdateHandler,
services,
}: DiscoverHistogramProps) {
const chartTheme = services.theme.useChartsTheme();
const chartBaseTheme = services.theme.useChartsBaseTheme();
private subscription?: Subscription;
public state = {
chartsTheme: getServices().theme.chartsDefaultTheme,
chartsBaseTheme: getServices().theme.chartsDefaultBaseTheme,
};
const dataState: DataChartsMessage = useDataState(savedSearchData$);
componentDidMount() {
this.subscription = combineLatest([
getServices().theme.chartsTheme$,
getServices().theme.chartsBaseTheme$,
]).subscribe(([chartsTheme, chartsBaseTheme]) =>
this.setState({ chartsTheme, chartsBaseTheme })
const uiSettings = services.uiSettings;
const timeZone = getTimezone(uiSettings);
const { chartData, fetchStatus } = dataState;
const onBrushEnd: BrushEndListener = useCallback(
({ 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 (this.subscription) {
this.subscription.unsubscribe();
}
if (!chartData) {
return null;
}
public onBrushEnd: BrushEndListener = ({ x }) => {
if (!x) {
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;
const formatXValue = (val: string) => {
const xAxisFormat = chartData.xAxisFormat.params!.pattern;
return moment(val).format(xAxisFormat);
};
public render() {
const uiSettings = getServices().uiSettings;
const timeZone = getTimezone(uiSettings);
const { chartData } = this.props;
const { chartsTheme, chartsBaseTheme } = this.state;
const data = chartData.values;
const isDarkMode = uiSettings.get('theme:darkMode');
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 isDarkMode = uiSettings.get('theme:darkMode');
const xValues = chartData.xAxisOrderedValues;
const lastXValue = xValues[xValues.length - 1];
/*
* 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 domain = chartData.ordered;
const domainStart = domain.min.valueOf();
const domainEnd = domain.max.valueOf();
const xValues = chartData.xAxisOrderedValues;
const lastXValue = xValues[xValues.length - 1];
const domainMin = Math.min(data[0]?.x, domainStart);
const domainMax = Math.max(domainEnd - xInterval, lastXValue);
const domain = chartData.ordered;
const domainStart = domain.min.valueOf();
const domainEnd = domain.max.valueOf();
const xDomain = {
min: domainMin,
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 domainMax = Math.max(domainEnd - xInterval, lastXValue);
const xAxisFormatter = services.data.fieldFormats.deserialize(chartData.yAxisFormat);
const xDomain = {
min: domainMin,
max: domainMax,
minInterval: getAdjustedInterval(
xValues,
intervalESValue,
intervalESUnit as unitOfTime.Base,
timeZone
),
};
const tooltipProps = {
headerFormatter: renderEndzoneTooltip(xInterval, domainStart, domainEnd, this.formatXValue),
type: TooltipType.VerticalCursor,
};
const xAxisFormatter = getServices().data.fieldFormats.deserialize(
this.props.chartData.yAxisFormat
);
return (
<Chart size="100%">
<Settings
xDomain={xDomain}
onBrushEnd={this.onBrushEnd}
onElementClick={this.onElementClick(xInterval)}
tooltip={tooltipProps}
theme={chartsTheme}
baseTheme={chartsBaseTheme}
/>
<Axis
id="discover-histogram-left-axis"
position={Position.Left}
ticks={5}
title={chartData.yAxisLabel}
integersOnly
tickFormat={(value) => xAxisFormatter.convert(value)}
/>
<Axis
id="discover-histogram-bottom-axis"
position={Position.Bottom}
title={chartData.xAxisLabel}
tickFormat={this.formatXValue}
ticks={10}
/>
<CurrentTime isDarkMode={isDarkMode} domainEnd={domainEnd} />
<Endzones
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>
);
}
return (
<Chart size="100%">
<Settings
xDomain={xDomain}
onBrushEnd={onBrushEnd}
onElementClick={onElementClick(xInterval)}
tooltip={tooltipProps}
theme={chartTheme}
baseTheme={chartBaseTheme}
/>
<Axis
id="discover-histogram-left-axis"
position={Position.Left}
ticks={5}
title={chartData.yAxisLabel}
integersOnly
tickFormat={(value) => xAxisFormatter.convert(value)}
/>
<Axis
id="discover-histogram-bottom-axis"
position={Position.Bottom}
title={chartData.xAxisLabel}
tickFormat={formatXValue}
ticks={10}
/>
<CurrentTime isDarkMode={isDarkMode} domainEnd={domainEnd} />
<Endzones
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 { HitsCounter, HitsCounterProps } from './hits_counter';
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 () {
let props: HitsCounterProps;
@ -20,7 +23,10 @@ describe('hits counter', function () {
props = {
onResetQuery: jest.fn(),
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', () => {
component = mountWithIntl(
<HitsCounter hits={2} showResetButton={false} onResetQuery={jest.fn()} />
);
component = mountWithIntl(<HitsCounter {...props} showResetButton={false} />);
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 () {
const data$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 1899,
}) as DataTotalHits$;
component = mountWithIntl(
<HitsCounter hits={1899} showResetButton={false} onResetQuery={jest.fn()} />
<HitsCounter
{...props}
savedSearchData$={data$}
showResetButton={false}
onResetQuery={jest.fn()}
/>
);
const hits = findTestSubject(component, 'discoverQueryHits');
expect(hits.text()).toBe('1,899');

View file

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

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;
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 { GetStateReturn } from '../../services/discover_state';
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 { FetchStatus } from '../../../../types';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { RequestAdapter } from '../../../../../../../inspector';
import { Chart } from '../chart/point_series';
setHeaderActionMenuMounter(jest.fn());
@ -37,23 +45,99 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps {
return { ...ip, ...{ attributes: { title: ip.title } } };
}) as unknown) as Array<SavedObject<IndexPatternAttributes>>;
const savedSearch$ = new BehaviorSubject({
state: FetchStatus.COMPLETE,
rows: esHits,
fetchCounter: 1,
fieldCounts: {},
hits: Number(esHits.length),
}) as SavedSearchDataSubject;
const main$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
foundDocuments: true,
}) as DataMain$;
const documents$ = new BehaviorSubject({
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 {
indexPattern,
indexPatternList,
inspectorAdapters: { requests: new RequestAdapter() },
navigateTo: jest.fn(),
onChangeIndexPattern: jest.fn(),
onUpdateQuery: jest.fn(),
resetQuery: jest.fn(),
savedSearch: savedSearchMock,
savedSearchData$: savedSearch$,
savedSearchData$,
savedSearchRefetch$: new Subject(),
searchSource: searchSourceMock,
services,

View file

@ -20,11 +20,10 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { I18nProvider } from '@kbn/i18n/react';
import classNames from 'classnames';
import { DiscoverNoResults } from '../no_results';
import { LoadingSpinner } from '../loading_spinner/loading_spinner';
import { DocTableLegacy } from '../../../../angular/doc_table/create_doc_table_react';
import {
esFilters,
IndexPatternField,
@ -32,42 +31,28 @@ import {
} from '../../../../../../../data/public';
import { DiscoverSidebarResponsive } from '../sidebar';
import { DiscoverLayoutProps } from './types';
import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort';
import {
DOC_HIDE_TIME_COLUMN_SETTING,
DOC_TABLE_LEGACY,
SAMPLE_SIZE_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
} from '../../../../../../common';
import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../../../common';
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 { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { DiscoverChart } from '../chart';
import { getResultState } from '../../utils/get_result_state';
import { InspectorSession } from '../../../../../../../inspector/public';
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 { DiscoverDocuments } from './discover_documents';
import { FetchStatus } from '../../../../types';
import { useDataState } from '../../utils/use_data_state';
const DocTableLegacyMemoized = React.memo(DocTableLegacy);
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const DataGridMemoized = React.memo(DiscoverGrid);
const TopNavMemoized = React.memo(DiscoverTopNav);
const DiscoverChartMemoized = React.memo(DiscoverChart);
interface DiscoverLayoutFetchState extends SavedSearchDataMessage {
state: FetchStatus;
fetchCounter: number;
fieldCounts: Record<string, number>;
rows: ElasticSearchHit[];
}
export function DiscoverLayout({
indexPattern,
indexPatternList,
inspectorAdapters,
navigateTo,
onChangeIndexPattern,
onUpdateQuery,
@ -82,38 +67,22 @@ export function DiscoverLayout({
}: DiscoverLayoutProps) {
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 [inspectorSession, setInspectorSession] = useState<InspectorSession | undefined>(undefined);
const scrollableDesktop = useRef<HTMLDivElement>(null);
const collapseIcon = useRef<HTMLButtonElement>(null);
const fetchCounter = useRef<number>(0);
const { main$, charts$, totalHits$ } = savedSearchData$;
const [fetchState, setFetchState] = useState<DiscoverLayoutFetchState>({
state: savedSearchData$.getValue().state,
fetchCounter: 0,
fieldCounts: {},
rows: [],
});
const { state: fetchStatus, fetchCounter, inspectorAdapters, rows } = fetchState;
const dataState: DataMainMsg = useDataState(main$);
useEffect(() => {
const subscription = savedSearchData$.subscribe((next) => {
if (
(next.state && next.state !== fetchState.state) ||
(next.fetchCounter && next.fetchCounter !== fetchState.fetchCounter) ||
(next.rows && next.rows !== fetchState.rows) ||
(next.chartData && next.chartData !== fetchState.chartData)
) {
setFetchState({ ...fetchState, ...next });
}
});
return () => {
subscription.unsubscribe();
};
}, [savedSearchData$, fetchState]);
if (dataState.fetchStatus === FetchStatus.LOADING) {
fetchCounter.current++;
}
}, [dataState.fetchStatus]);
// 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(() => {
return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined;
}, [indexPattern]);
@ -122,27 +91,18 @@ export function DiscoverLayout({
const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]);
const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]);
const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]);
const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({
capabilities,
config: uiSettings,
indexPattern,
indexPatterns,
setAppState: stateContainer.setAppState,
state,
useNewFieldsApi,
});
const resultState = useMemo(
() => getResultState(dataState.fetchStatus, dataState.foundDocuments!),
[dataState.fetchStatus, dataState.foundDocuments]
);
const onOpenInspector = useCallback(() => {
// prevent overlapping
if (inspectorAdapters) {
setExpandedDoc(undefined);
const session = services.inspector.open(inspectorAdapters, {
title: savedSearch.title,
});
setInspectorSession(session);
}
setExpandedDoc(undefined);
const session = services.inspector.open(inspectorAdapters, {
title: savedSearch.title,
});
setInspectorSession(session);
}, [setExpandedDoc, inspectorAdapters, savedSearch, services.inspector]);
useEffect(() => {
@ -154,12 +114,15 @@ export function DiscoverLayout({
};
}, [inspectorSession]);
const onSort = useCallback(
(sort: string[][]) => {
stateContainer.setAppState({ sort });
},
[stateContainer]
);
const { columns, onAddColumn, onRemoveColumn } = useDataGridColumns({
capabilities,
config: uiSettings,
indexPattern,
indexPatterns,
setAppState: stateContainer.setAppState,
state,
useNewFieldsApi,
});
const onAddFilter = useCallback(
(field: IndexPatternField | string, values: string, operation: '+' | '-') => {
@ -179,33 +142,6 @@ export function DiscoverLayout({
},
[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(() => {
savedSearchRefetch$.next('reset');
@ -219,14 +155,10 @@ export function DiscoverLayout({
}, [filterManager]);
const contentCentered = resultState === 'uninitialized' || resultState === 'none';
const showTimeCol = useMemo(
() => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName,
[uiSettings, indexPattern.timeFieldName]
);
return (
<I18nProvider>
<EuiPage className="dscPage" data-fetch-counter={fetchCounter}>
<EuiPage className="dscPage" data-fetch-counter={fetchCounter.current}>
<TopNavMemoized
indexPattern={indexPattern}
onOpenInspector={onOpenInspector}
@ -247,8 +179,7 @@ export function DiscoverLayout({
<EuiFlexItem grow={false}>
<SidebarMemoized
columns={columns}
fieldCounts={fetchState.fieldCounts}
hits={rows}
documents$={savedSearchData$.documents$}
indexPatternList={indexPatternList}
onAddField={onAddColumn}
onAddFilter={onAddFilter}
@ -298,7 +229,7 @@ export function DiscoverLayout({
<DiscoverNoResults
timeFieldName={timeField}
data={data}
error={fetchState.fetchError}
error={dataState.error}
hasQuery={!!state.query?.query}
hasFilters={
state.filters && state.filters.filter((f) => !f.meta.disabled).length > 0
@ -320,82 +251,31 @@ export function DiscoverLayout({
>
<EuiFlexItem grow={false}>
<DiscoverChartMemoized
config={uiSettings}
chartData={fetchState.chartData}
bucketInterval={fetchState.bucketInterval}
data={data}
hits={fetchState.hits}
indexPattern={indexPattern}
isLegacy={isLegacy}
state={state}
resetQuery={resetQuery}
savedSearch={savedSearch}
savedSearchDataChart$={charts$}
savedSearchDataTotalHits$={totalHits$}
services={services}
stateContainer={stateContainer}
timefield={timeField}
/>
</EuiFlexItem>
<EuiHorizontalRule margin="none" />
<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 && 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>
<DiscoverDocuments
documents$={savedSearchData$.documents$}
expandedDoc={expandedDoc}
indexPattern={indexPattern}
isMobile={isMobile}
navigateTo={navigateTo}
onAddFilter={onAddFilter as DocViewFilterFn}
savedSearch={savedSearch}
services={services}
setExpandedDoc={setExpandedDoc}
state={state}
stateContainer={stateContainer}
/>
</EuiFlexGroup>
)}
</EuiPageContent>

View file

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

View file

@ -63,7 +63,7 @@ function getCompProps(): DiscoverSidebarProps {
return {
columns: ['extension'],
fieldCounts,
hits,
documents: hits,
indexPatternList,
onChangeIndexPattern: 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 { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
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
*/
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, ...
*/
@ -64,6 +65,15 @@ export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
setFieldEditorRef?: (ref: () => void | undefined) => 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({
@ -71,7 +81,7 @@ export function DiscoverSidebar({
columns,
fieldCounts,
fieldFilter,
hits,
documents,
indexPatternList,
onAddField,
onAddFilter,
@ -101,7 +111,7 @@ export function DiscoverSidebar({
useEffect(() => {
const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts);
setFields(newFields);
}, [selectedIndexPattern, fieldCounts, hits]);
}, [selectedIndexPattern, fieldCounts, documents]);
const scrollDimensions = useResizeObserver(scrollContainer);
@ -115,8 +125,8 @@ export function DiscoverSidebar({
);
const getDetailsByField = useCallback(
(ipField: IndexPatternField) => getDetails(ipField, hits, columns, selectedIndexPattern),
[hits, columns, selectedIndexPattern]
(ipField: IndexPatternField) => getDetails(ipField, documents, columns, selectedIndexPattern),
[documents, columns, selectedIndexPattern]
);
const popularLimit = useMemo(() => services.uiSettings.get(FIELDS_LIMIT_SETTING), [

View file

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

View file

@ -6,7 +6,7 @@
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -33,9 +33,10 @@ import { IndexPatternField, IndexPattern } from '../../../../../../../data/publi
import { getDefaultFieldFilter } from './lib/field_filter';
import { DiscoverSidebar } from './discover_sidebar';
import { DiscoverServices } from '../../../../../build_services';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { AppState } from '../../services/discover_state';
import { DiscoverIndexPatternManagement } from './discover_index_pattern_management';
import { DataDocuments$ } from '../../services/use_saved_search';
import { calcFieldCounts } from '../../utils/calc_field_counts';
export interface DiscoverSidebarResponsiveProps {
/**
@ -46,14 +47,10 @@ export interface DiscoverSidebarResponsiveProps {
* the selected columns displayed in the doc table in discover
*/
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: ElasticSearchHit[];
documents$: DataDocuments$;
/**
* List of available index patterns
*/
@ -119,6 +116,36 @@ export interface DiscoverSidebarResponsiveProps {
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter());
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>();
@ -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) {
return null;
}
const setFieldEditorRef = (ref: () => void | undefined) => {
closeFieldEditor.current = ref;
};
const closeFlyout = () => {
setIsFlyoutVisible(false);
};
const { indexPatternFieldEditor } = props.services;
const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern();
const canEditIndexPatternField = !!indexPatternFieldEditPermission && props.useNewFieldsApi;
@ -177,7 +204,9 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
<EuiHideFor sizes={['xs', 's']}>
<DiscoverSidebar
{...props}
documents={documentState.result ?? []}
fieldFilter={fieldFilter}
fieldCounts={fieldCounts.current}
setFieldFilter={setFieldFilter}
editField={editField}
/>
@ -262,6 +291,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
<div className="euiFlyoutBody">
<DiscoverSidebar
{...props}
documents={documentState.result ?? []}
fieldCounts={fieldCounts.current}
fieldFilter={fieldFilter}
setFieldFilter={setFieldFilter}
alwaysShowActionButtons={true}

View file

@ -13,7 +13,61 @@ import { TimechartHeader, TimechartHeaderProps } from './timechart_header';
import { EuiIconTip } from '@elastic/eui';
import { findTestSubject } from '@elastic/eui/lib/test';
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 () {
let props: TimechartHeaderProps;
let component: ReactWrapper<TimechartHeaderProps>;
@ -48,11 +102,16 @@ describe('timechart header', function () {
},
],
onChangeInterval: jest.fn(),
bucketInterval: {
scaled: undefined,
description: 'second',
scale: undefined,
},
savedSearchData$: new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
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', () => {
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} />);
expect(component.find(EuiIconTip).length).toBe(1);
});

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,6 @@ describe('test useSavedSearch', () => {
const { result } = renderHook(() => {
return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING,
searchSessionManager,
searchSource: savedSearchMock.searchSource.createCopy(),
@ -39,11 +38,10 @@ describe('test useSavedSearch', () => {
});
expect(result.current.refetch$).toBeInstanceOf(Subject);
expect(result.current.data$.value).toMatchInlineSnapshot(`
Object {
"state": "loading",
}
`);
expect(result.current.data$.main$.getValue().fetchStatus).toBe(FetchStatus.LOADING);
expect(result.current.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.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 () => {
const { history, searchSessionManager } = createSearchSessionMock();
@ -68,7 +66,6 @@ describe('test useSavedSearch', () => {
const { result, waitForValueToChange } = renderHook(() => {
return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING,
searchSessionManager,
searchSource: resultState.current.searchSource,
@ -81,11 +78,11 @@ describe('test useSavedSearch', () => {
result.current.refetch$.next();
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$.value.rows).toEqual([]);
expect(result.current.data$.totalHits$.value.result).toBe(0);
expect(result.current.data$.documents$.value.result).toEqual([]);
});
test('reset sets back to initial state', async () => {
@ -111,7 +108,6 @@ describe('test useSavedSearch', () => {
const { result, waitForValueToChange } = renderHook(() => {
return useSavedSearch({
indexPattern: indexPatternMock,
initialFetchStatus: FetchStatus.LOADING,
searchSessionManager,
searchSource: resultState.current.searchSource,
@ -124,10 +120,10 @@ describe('test useSavedSearch', () => {
result.current.refetch$.next();
await waitForValueToChange(() => {
return result.current.data$.value.state === FetchStatus.COMPLETE;
return result.current.data$.main$.value.fetchStatus === FetchStatus.COMPLETE;
});
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
* Side Public License, v 1.
*/
import { useEffect, useRef, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { merge, Subject, BehaviorSubject } from 'rxjs';
import { debounceTime, tap, filter } from 'rxjs/operators';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { BehaviorSubject, merge, Subject } from 'rxjs';
import { debounceTime, filter, tap } from 'rxjs/operators';
import { DiscoverServices } from '../../../../build_services';
import { DiscoverSearchSessionManager } from './discover_search_session';
import {
IndexPattern,
isCompleteResponse,
SearchSource,
tabifyAggResponse,
} from '../../../../../../data/common';
import { SearchSource } from '../../../../../../data/common';
import { GetStateReturn } from './discover_state';
import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { RequestAdapter } from '../../../../../../inspector/public';
import { AutoRefreshDoneFn, search } from '../../../../../../data/public';
import { calcFieldCounts } from '../utils/calc_field_counts';
import { AutoRefreshDoneFn } from '../../../../../../data/public';
import { validateTimeRange } from '../utils/validate_time_range';
import { updateSearchSource } from '../utils/update_search_source';
import { SortOrder } from '../../../../saved_searches/types';
import { getDimensions, getChartAggConfigs } from '../utils';
import { buildPointSeriesData, Chart } from '../components/chart/point_series';
import { Chart } from '../components/chart/point_series';
import { TimechartBucketInterval } from '../components/timechart_header/timechart_header';
import { useSingleton } from '../utils/use_singleton';
import { FetchStatus } from '../../../types';
export type SavedSearchDataSubject = BehaviorSubject<SavedSearchDataMessage>;
export type SavedSearchRefetchSubject = Subject<SavedSearchRefetchMsg>;
import { fetchAll } from '../utils/fetch_all';
import { useBehaviorSubject } from '../utils/use_behavior_subject';
import { sendResetMsg } from './use_saved_search_messages';
export interface UseSavedSearch {
refetch$: SavedSearchRefetchSubject;
data$: SavedSearchDataSubject;
reset: () => void;
export interface SavedSearchData {
main$: DataMain$;
documents$: DataDocuments$;
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;
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
*/
export const useSavedSearch = ({
indexPattern,
initialFetchStatus,
searchSessionManager,
searchSource,
@ -67,244 +84,148 @@ export const useSavedSearch = ({
stateContainer,
useNewFieldsApi,
}: {
indexPattern: IndexPattern;
initialFetchStatus: FetchStatus;
searchSessionManager: DiscoverSearchSessionManager;
searchSource: SearchSource;
services: DiscoverServices;
stateContainer: GetStateReturn;
useNewFieldsApi: boolean;
}): UseSavedSearch => {
}) => {
const { data, filterManager } = services;
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)
*/
const data$: SavedSearchDataSubject = useSingleton(
() =>
new BehaviorSubject<SavedSearchDataMessage>({
state: initialFetchStatus,
})
);
const main$: DataMain$ = useBehaviorSubject({ fetchStatus: initialFetchStatus });
const documents$: DataDocuments$ = useBehaviorSubject({ fetchStatus: 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
* By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields
* 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
*/
const refs = useRef<{
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
* to an observable of various possible changes in state
*/
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(
refetch$,
filterManager.getFetches$(),
timefilter.getFetch$(),
timefilter.getAutoRefreshFetch$().pipe(
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$(),
searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId))
).pipe(debounceTime(100));
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 {
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) {
data$.next({
state: FetchStatus.ERROR,
fetchError: error,
main$.next({
fetchStatus: FetchStatus.ERROR,
error,
});
}
});
return () => {
subscription.unsubscribe();
};
return () => subscription.unsubscribe();
}, [
data$,
data,
data.query.queryString,
dataSubjects,
filterManager,
initialFetchStatus,
inspectorAdapters,
main$,
refetch$,
searchSessionManager,
searchSessionManager.newSearchSessionIdFromURL$,
searchSource,
services,
services.toastNotifications,
stateContainer.appStateContainer,
timefilter,
fetchAll,
useNewFieldsApi,
]);
const reset = useCallback(() => sendResetMsg(dataSubjects, initialFetchStatus), [
dataSubjects,
initialFetchStatus,
]);
return {
refetch$,
data$,
reset: sendResetMsg,
data$: dataSubjects,
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(
counts = {} as Record<string, number>,
rows: ElasticSearchHit[],
indexPattern: IndexPattern
rows?: ElasticSearchHit[],
indexPattern?: IndexPattern
) {
if (!rows || !indexPattern) {
return {};
}
for (const hit of rows) {
const fields = Object.keys(indexPattern.flattenHit(hit));
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.
*/
import { getResultState, resultStatuses } from './get_result_state';
import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../types';
describe('getResultState', () => {
test('fetching uninitialized', () => {
const actual = getResultState(FetchStatus.UNINITIALIZED, []);
const actual = getResultState(FetchStatus.UNINITIALIZED, false);
expect(actual).toBe(resultStatuses.UNINITIALIZED);
});
test('fetching complete with no records', () => {
const actual = getResultState(FetchStatus.COMPLETE, []);
const actual = getResultState(FetchStatus.COMPLETE, false);
expect(actual).toBe(resultStatuses.NO_RESULTS);
});
test('fetching ongoing aka loading', () => {
const actual = getResultState(FetchStatus.LOADING, []);
const actual = getResultState(FetchStatus.LOADING, false);
expect(actual).toBe(resultStatuses.LOADING);
});
test('fetching ready', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit;
const actual = getResultState(FetchStatus.COMPLETE, [record]);
const actual = getResultState(FetchStatus.COMPLETE, true);
expect(actual).toBe(resultStatuses.READY);
});
test('re-fetching after already data is available', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit;
const actual = getResultState(FetchStatus.LOADING, [record]);
const actual = getResultState(FetchStatus.LOADING, true);
expect(actual).toBe(resultStatuses.READY);
});
test('after a fetch error when data was successfully fetched before ', () => {
const record = ({ _id: 123 } as unknown) as ElasticSearchHit;
const actual = getResultState(FetchStatus.ERROR, [record]);
const actual = getResultState(FetchStatus.ERROR, true);
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
* Side Public License, v 1.
*/
import { ElasticSearchHit } from '../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../types';
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
* 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) {
return resultStatuses.UNINITIALIZED;
}
const rowsEmpty = !Array.isArray(rows) || rows.length === 0;
if (rowsEmpty && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING;
else if (!rowsEmpty) return resultStatuses.READY;
if (!foundDocuments && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING;
else if (foundDocuments) return resultStatuses.READY;
else if (fetchStatus === FetchStatus.PARTIAL) return resultStatuses.READY;
else return resultStatuses.NO_RESULTS;
}

View file

@ -9,39 +9,21 @@
import { updateSearchSource } from './update_search_source';
import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks';
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 { discoverServiceMock } from '../../../../__mocks__/services';
describe('updateSearchSource', () => {
test('updates a given search source', async () => {
const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock,
services: ({
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,
services: discoverServiceMock,
sort: [] as SortOrder[],
useNewFieldsApi: false,
});
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toBe(undefined);
});
@ -49,28 +31,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock,
services: ({
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,
services: discoverServiceMock,
sort: [] as SortOrder[],
useNewFieldsApi: true,
});
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' },
]);
@ -81,28 +48,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock,
services: ({
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,
services: discoverServiceMock,
sort: [] as SortOrder[],
useNewFieldsApi: true,
});
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual([
{ field: '*', include_unmapped: 'true' },
]);
@ -113,28 +65,13 @@ describe('updateSearchSource', () => {
const persistentSearchSourceMock = createSearchSourceMock({});
const volatileSearchSourceMock = createSearchSourceMock({});
volatileSearchSourceMock.setParent(persistentSearchSourceMock);
const sampleSize = 250;
updateSearchSource(volatileSearchSourceMock, false, {
indexPattern: indexPatternMock,
services: ({
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,
services: discoverServiceMock,
sort: [] as SortOrder[],
useNewFieldsApi: false,
});
expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock);
expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize);
expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined);
expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined);
});

View file

@ -7,7 +7,7 @@
*/
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 { SortOrder } from '../../../../saved_searches/types';
import { DiscoverServices } from '../../../../build_services';
@ -32,25 +32,22 @@ export function updateSearchSource(
}
) {
const { uiSettings, data } = services;
const usedSort = getSortForSearchSource(
sort,
indexPattern,
uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
);
const usedSearchSource = persist ? searchSource : searchSource.getParent()!;
const parentSearchSource = persist ? searchSource : searchSource.getParent()!;
usedSearchSource
parentSearchSource
.setField('index', indexPattern)
.setField('query', data.query.queryString.getQuery() || null)
.setField('filter', data.query.filterManager.getFilters());
if (!persist) {
const usedSort = getSortForSearchSource(
sort,
indexPattern,
uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
);
searchSource
.setField('trackTotalHits', true)
.setField('size', uiSettings.get(SAMPLE_SIZE_SETTING))
.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
// document-like response.
.setPreferredSearchStrategyId('default');

View file

@ -5,12 +5,15 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* 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);
/**
* Converts a number to a string and adds commas
* as thousands separators
*/
export const formatNumWithCommas = (input: number) =>
String(input).replace(COMMA_SEPARATOR_RE, '$1,');
if (ref.current === null) {
ref.current = new BehaviorSubject(props);
}
return ref.current;
}

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

View file

@ -37,6 +37,7 @@ export function buildSearchBody(
},
stored_fields: computedFields.storedFields,
script_fields: computedFields.scriptFields,
version: true,
},
};
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 {
UNINITIALIZED = 'uninitialized',
LOADING = 'loading',
PARTIAL = 'partial',
COMPLETE = 'complete',
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 () {
// this is the number of renderings of the histogram needed when new data is fetched
// this needs to be improved
const renderingCountInc = 1;
const renderingCountInc = 2;
const prevRenderingCount = await elasticChart.getVisualizationRenderingCount();
await PageObjects.timePicker.setDefaultAbsoluteRange();
await PageObjects.discover.waitUntilSearchingHasFinished();

View file

@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const inspector = getService('inspector');
const testSubjects = getService('testSubjects');
const STATS_ROW_NAME_INDEX = 0;
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 () => {
await inspector.open();
const requestStats = await inspector.getTableData();
expect(getHitCount(requestStats)).to.be('0');
await testSubjects.click('inspectorRequestChooser');
let foundZero = false;
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 () => {
await PageObjects.timePicker.setDefaultAbsoluteRange();
await inspector.open();
await testSubjects.click('inspectorRequestChooser');
await testSubjects.click(`inspectorRequestChooserDocuments`);
await testSubjects.click(`inspectorRequestDetailStatistics`);
const requestStats = await inspector.getTableData();
expect(getHitCount(requestStats)).to.be('500');

View file

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

View file

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