[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:
parent
7e12ea84d5
commit
47f5f81765
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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%;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -63,7 +63,7 @@ function getCompProps(): DiscoverSidebarProps {
|
|||
return {
|
||||
columns: ['extension'],
|
||||
fieldCounts,
|
||||
hits,
|
||||
documents: hits,
|
||||
indexPatternList,
|
||||
onChangeIndexPattern: jest.fn(),
|
||||
onAddFilter: jest.fn(),
|
||||
|
|
|
@ -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), [
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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$;
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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$;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
|
|
@ -37,6 +37,7 @@ export function buildSearchBody(
|
|||
},
|
||||
stored_fields: computedFields.storedFields,
|
||||
script_fields: computedFields.scriptFields,
|
||||
version: true,
|
||||
},
|
||||
};
|
||||
if (!request.body) {
|
||||
|
|
|
@ -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';
|
|
@ -9,6 +9,7 @@
|
|||
export enum FetchStatus {
|
||||
UNINITIALIZED = 'uninitialized',
|
||||
LOADING = 'loading',
|
||||
PARTIAL = 'partial',
|
||||
COMPLETE = 'complete',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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": "クリップボードにコピー",
|
||||
|
|
|
@ -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": "复制到剪贴板",
|
||||
|
|
Loading…
Reference in a new issue