[Discover] Migrate angular context app controllers to react (#100530)

* [Discover] migrate remaining context files from js to ts

* [Discover] get rid of any types

* [Discover] replace constants with enums, update imports

* [Discover] use unknown instead of any, correct types

* [Discover] skip any type for tests

* [Discover] add euiDataGrid view

* [Discover] add support dataGrid columns, provide ability to do not change sorting, highlight anchor doc, rename legacy variables

* [Discover] update context_legacy test and types

* [Discover] update unit tests, add context header

* [Discover] update unit and functional tests

* [Discover] remove docTable from context test which uses new data grid

* [Discover] update EsHitRecord type, use it for context app. add no pagination support

* [Discover] resolve type error in test

* [Discover] move fetching methods

* [Discover] complete fetching part

* [Discover] remove redundant controller

* [Discover] split up context state and components

* [Discover] revert redundant css class

* [Discover] rename component

* [Discover] revert to upstream changes

* [Discover] return upstream changes

* [Discover] refactoring, context test update

* [Discover] add tests for fetching methods, remove redundant files

* [Discover] remove redundant angular utils, add filter test

* [Discover] refactor error feedback

* [Discover] fix functional test

* [Discover] provide defaultSize

* [Discover] clean up code

* [Discover] fix eslint error

* [Discover] fiix context settings

* [Discover] return tieBreaker field check

* [Discover] optimize things

* [Discover] optimize rerenders

* Update src/plugins/discover/public/application/components/context_app/context_app.tsx

Co-authored-by: Matthias Wilhelm <ankertal@gmail.com>

* [Discover] resolve comments

* [Discover] replace url instead of pushing to history. refactoring

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Matthias Wilhelm <ankertal@gmail.com>
This commit is contained in:
Dmitry Tomashevich 2021-06-17 16:27:26 +03:00 committed by GitHub
parent 246e7be3e5
commit 0cfd04c87d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1200 additions and 1158 deletions

View file

@ -18,4 +18,5 @@ export const indexPatternsMock = ({
return indexPatternMock;
}
},
} as unknown) as IndexPatternsService;
updateSavedObject: jest.fn(),
} as unknown) as jest.Mocked<IndexPatternsService>;

View file

@ -7,7 +7,13 @@
*/
import { IUiSettingsClient } from 'kibana/public';
import { DEFAULT_COLUMNS_SETTING, DOC_TABLE_LEGACY, SAMPLE_SIZE_SETTING } from '../../common';
import {
CONTEXT_TIE_BREAKER_FIELDS_SETTING,
DEFAULT_COLUMNS_SETTING,
DOC_TABLE_LEGACY,
SAMPLE_SIZE_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
} from '../../common';
export const uiSettingsMock = ({
get: (key: string) => {
@ -17,6 +23,10 @@ export const uiSettingsMock = ({
return ['default_column'];
} else if (key === DOC_TABLE_LEGACY) {
return true;
} else if (key === CONTEXT_TIE_BREAKER_FIELDS_SETTING) {
return ['_doc'];
} else if (key === SEARCH_FIELDS_FROM_SOURCE) {
return false;
}
},
} as unknown) as IUiSettingsClient;

View file

@ -1,10 +1,5 @@
<context-app
anchor-id="contextAppRoute.anchorId"
columns="contextAppRoute.state.columns"
<context-app-legacy
index-pattern="contextAppRoute.indexPattern"
app-state="contextAppRoute.state"
state-container="contextAppRoute.stateContainer"
filters="contextAppRoute.filters"
predecessor-count="contextAppRoute.state.predecessorCount"
successor-count="contextAppRoute.state.successorCount"
sort="contextAppRoute.state.sort"></context-app>
index-pattern-id="contextAppRoute.indexPatternId"
anchor-id="contextAppRoute.anchorId">
</context-app-legacy>

View file

@ -6,12 +6,8 @@
* Side Public License, v 1.
*/
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../common';
import { getAngularModule, getServices } from '../../kibana_services';
import './context_app';
import { getState } from './context_state';
import contextAppRouteTemplate from './context.html';
import { getRootBreadcrumbs } from '../helpers/breadcrumbs';
@ -28,9 +24,14 @@ const k7Breadcrumbs = () => {
getAngularModule().config(($routeProvider) => {
$routeProvider.when('/context/:indexPatternId/:id*', {
controller: ContextAppRouteController,
controller: function ($routeParams, $scope, $route) {
this.indexPattern = $route.current.locals.indexPattern.ip;
this.anchorId = $routeParams.id;
this.indexPatternId = $route.current.params.indexPatternId;
},
k7Breadcrumbs,
controllerAs: 'contextAppRoute',
reloadOnSearch: false,
resolve: {
indexPattern: ($route, Promise) => {
const indexPattern = getServices().indexPatterns.get($route.current.params.indexPatternId);
@ -40,57 +41,3 @@ getAngularModule().config(($routeProvider) => {
template: contextAppRouteTemplate,
});
});
function ContextAppRouteController($routeParams, $scope, $route) {
const filterManager = getServices().filterManager;
const indexPattern = $route.current.locals.indexPattern.ip;
const stateContainer = getState({
defaultStepSize: getServices().uiSettings.get(CONTEXT_DEFAULT_SIZE_SETTING),
timeFieldName: indexPattern.timeFieldName,
storeInSessionStorage: getServices().uiSettings.get('state:storeInSessionStorage'),
history: getServices().history(),
toasts: getServices().core.notifications.toasts,
uiSettings: getServices().core.uiSettings,
});
const {
startSync: startStateSync,
stopSync: stopStateSync,
appState,
getFilters,
setFilters,
setAppState,
flushToUrl,
} = stateContainer;
this.stateContainer = stateContainer;
this.state = { ...appState.getState() };
this.anchorId = $routeParams.id;
this.indexPattern = indexPattern;
filterManager.setFilters(_.cloneDeep(getFilters()));
startStateSync();
// take care of parameter changes in UI
$scope.$watchGroup(
[
'contextAppRoute.state.columns',
'contextAppRoute.state.predecessorCount',
'contextAppRoute.state.successorCount',
],
(newValues) => {
const [columns, predecessorCount, successorCount] = newValues;
if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) {
setAppState({ columns, predecessorCount, successorCount });
flushToUrl(true);
}
}
);
// take care of parameter filter changes
const filterObservable = filterManager.getUpdates$().subscribe(() => {
setFilters(filterManager);
$route.reload();
});
$scope.$on('$destroy', () => {
stopStateSync();
filterObservable.unsubscribe();
});
}

View file

@ -9,7 +9,7 @@
import moment from 'moment';
import { get, last } from 'lodash';
import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs';
import { EsHitRecordList, fetchContextProvider } from './context';
import { EsHitRecordList, fetchContextProvider, SurrDocType } from './context';
import { setServices, SortDirection } from '../../../../kibana_services';
import { EsHitRecord } from './context';
import { Query } from '../../../../../../data/public';
@ -73,7 +73,7 @@ describe('context app', function () {
};
return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs(
'predecessors',
SurrDocType.PREDECESSORS,
indexPatternId,
anchor as EsHitRecord,
timeField,
@ -265,7 +265,7 @@ describe('context app', function () {
};
return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs(
'predecessors',
SurrDocType.PREDECESSORS,
indexPatternId,
anchor as EsHitRecord,
timeField,

View file

@ -12,7 +12,7 @@ import { get, last } from 'lodash';
import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs';
import { setServices, SortDirection } from '../../../../kibana_services';
import { Query } from '../../../../../../data/public';
import { EsHitRecordList, fetchContextProvider } from './context';
import { EsHitRecordList, fetchContextProvider, SurrDocType } from './context';
import { EsHitRecord } from './context';
import { DiscoverServices } from '../../../../build_services';
@ -73,7 +73,7 @@ describe('context app', function () {
};
return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs(
'successors',
SurrDocType.SUCCESSORS,
indexPatternId,
anchor as EsHitRecord,
timeField,
@ -268,7 +268,7 @@ describe('context app', function () {
};
return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs(
'successors',
SurrDocType.SUCCESSORS,
indexPatternId,
anchor as EsHitRecord,
timeField,

View file

@ -16,7 +16,11 @@ import { getEsQuerySearchAfter } from './utils/get_es_query_search_after';
import { getEsQuerySort } from './utils/get_es_query_sort';
import { getServices } from '../../../../kibana_services';
export type SurrDocType = 'successors' | 'predecessors';
export enum SurrDocType {
SUCCESSORS = 'successors',
PREDECESSORS = 'predecessors',
}
export type EsHitRecord = Required<
Pick<
estypes.SearchResponse['hits']['hits'][number],
@ -68,7 +72,7 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFields
}
const indexPattern = await indexPatterns.get(indexPatternId);
const searchSource = await createSearchSource(indexPattern, filters);
const sortDirToApply = type === 'successors' ? sortDir : reverseSortDir(sortDir);
const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir);
const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : '';
const timeValueMillis =
@ -108,7 +112,9 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFields
);
documents =
type === 'successors' ? [...documents, ...hits] : [...hits.slice().reverse(), ...documents];
type === SurrDocType.SUCCESSORS
? [...documents, ...hits]
: [...hits.slice().reverse(), ...documents];
}
return documents;

View file

@ -7,6 +7,7 @@
*/
import { SortDirection } from '../../../../../../../data/public';
import { SurrDocType } from '../context';
export type IntervalValue = number | null;
@ -31,12 +32,12 @@ export function* asPairs(iterable: Iterable<IntervalValue>): IterableIterator<In
export function generateIntervals(
offsets: number[],
startTime: number,
type: string,
type: SurrDocType,
sort: SortDirection
): IterableIterator<IntervalValue[]> {
const offsetSign =
(sort === SortDirection.asc && type === 'successors') ||
(sort === SortDirection.desc && type === 'predecessors')
(sort === SortDirection.asc && type === SurrDocType.SUCCESSORS) ||
(sort === SortDirection.desc && type === SurrDocType.PREDECESSORS)
? 1
: -1;
// ending with `null` opens the last interval

View file

@ -26,7 +26,8 @@ export function getEsQuerySearchAfter(
): EsQuerySearchAfter {
if (documents.length) {
// already surrounding docs -> first or last record is used
const afterTimeRecIdx = type === 'successors' && documents.length ? documents.length - 1 : 0;
const afterTimeRecIdx =
type === SurrDocType.SUCCESSORS && documents.length ? documents.length - 1 : 0;
const afterTimeDoc = documents[afterTimeRecIdx];
let afterTimeValue = afterTimeDoc.sort[0] as string | number;
if (nanoSeconds) {

View file

@ -10,10 +10,14 @@ import React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { ActionBar, ActionBarProps } from './action_bar';
import { findTestSubject } from '@elastic/eui/lib/test';
import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants';
import {
MAX_CONTEXT_SIZE,
MIN_CONTEXT_SIZE,
} from '../../../../components/context_app/utils/constants';
import { SurrDocType } from '../../api/context';
describe('Test Discover Context ActionBar for successor | predecessor records', () => {
['successors', 'predecessors'].forEach((type) => {
[SurrDocType.SUCCESSORS, SurrDocType.PREDECESSORS].forEach((type) => {
const onChangeCount = jest.fn();
const props = {
defaultStepSize: 5,
@ -31,7 +35,7 @@ describe('Test Discover Context ActionBar for successor | predecessor records',
test(`${type}: Load button click`, () => {
btn.simulate('click');
expect(onChangeCount).toHaveBeenCalledWith(25);
expect(onChangeCount).toHaveBeenCalledWith(type, 25);
});
test(`${type}: Load button click doesnt submit when MAX_CONTEXT_SIZE was reached`, () => {
@ -44,13 +48,13 @@ describe('Test Discover Context ActionBar for successor | predecessor records',
test(`${type}: Count input change submits on blur`, () => {
input.simulate('change', { target: { valueAsNumber: 123 } });
input.simulate('blur');
expect(onChangeCount).toHaveBeenCalledWith(123);
expect(onChangeCount).toHaveBeenCalledWith(type, 123);
});
test(`${type}: Count input change submits on return`, () => {
input.simulate('change', { target: { valueAsNumber: 124 } });
input.simulate('submit');
expect(onChangeCount).toHaveBeenCalledWith(124);
expect(onChangeCount).toHaveBeenCalledWith(type, 124);
});
test(`${type}: Count input doesnt submits values higher than MAX_CONTEXT_SIZE `, () => {
@ -68,7 +72,7 @@ describe('Test Discover Context ActionBar for successor | predecessor records',
});
test(`${type}: Warning about limitation of additional records`, () => {
if (type === 'predecessors') {
if (type === SurrDocType.PREDECESSORS) {
expect(findTestSubject(wrapper, 'predecessorsWarningMsg').text()).toBe(
'No documents newer than the anchor could be found.'
);

View file

@ -19,7 +19,10 @@ import {
} from '@elastic/eui';
import { ActionBarWarning } from './action_bar_warning';
import { SurrDocType } from '../../api/context';
import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants';
import {
MAX_CONTEXT_SIZE,
MIN_CONTEXT_SIZE,
} from '../../../../components/context_app/utils/constants';
export interface ActionBarProps {
/**
@ -45,9 +48,10 @@ export interface ActionBarProps {
isLoading: boolean;
/**
* is triggered when the input containing count is changed
* @param type
* @param count
*/
onChangeCount: (count: number) => void;
onChangeCount: (type: SurrDocType, count: number) => void;
/**
* can be `predecessors` or `successors`, usage in context:
* predecessors action bar + records (these are newer records)
@ -67,13 +71,13 @@ export function ActionBar({
type,
}: ActionBarProps) {
const showWarning = !isDisabled && !isLoading && docCountAvailable < docCount;
const isSuccessor = type === 'successors';
const isSuccessor = type === SurrDocType.SUCCESSORS;
const [newDocCount, setNewDocCount] = useState(docCount);
const isValid = (value: number) => value >= MIN_CONTEXT_SIZE && value <= MAX_CONTEXT_SIZE;
const onSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (newDocCount !== docCount && isValid(newDocCount)) {
onChangeCount(newDocCount);
onChangeCount(type, newDocCount);
}
};
useEffect(() => {
@ -100,7 +104,7 @@ export function ActionBar({
const value = newDocCount + defaultStepSize;
if (isValid(value)) {
setNewDocCount(value);
onChangeCount(value);
onChangeCount(type, value);
}
}}
flush="right"
@ -131,7 +135,7 @@ export function ActionBar({
}}
onBlur={() => {
if (newDocCount !== docCount && isValid(newDocCount)) {
onChangeCount(newDocCount);
onChangeCount(type, newDocCount);
}
}}
type="number"

View file

@ -12,7 +12,7 @@ import { EuiCallOut } from '@elastic/eui';
import { SurrDocType } from '../../api/context';
export function ActionBarWarning({ docCount, type }: { docCount: number; type: SurrDocType }) {
if (type === 'predecessors') {
if (type === SurrDocType.PREDECESSORS) {
return (
<EuiCallOut
color="warning"

View file

@ -1,210 +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.
*/
import React from 'react';
import { fromPairs } from 'lodash';
import { i18n } from '@kbn/i18n';
import { getServices } from '../../../../kibana_services';
import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common';
import { MarkdownSimple, toMountPoint } from '../../../../../../kibana_react/public';
import { fetchAnchorProvider } from '../api/anchor';
import { EsHitRecord, EsHitRecordList, fetchContextProvider, SurrDocType } from '../api/context';
import { getQueryParameterActions } from '../query_parameters';
import {
ContextAppState,
FailureReason,
LoadingStatus,
LoadingStatusEntry,
LoadingStatusState,
QueryParameters,
} from '../../context_app_state';
interface DiscoverPromise extends PromiseConstructor {
try: <T>(fn: () => Promise<T>) => Promise<T>;
}
export function QueryActionsProvider(Promise: DiscoverPromise) {
const { filterManager, indexPatterns, data, uiSettings } = getServices();
const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE);
const fetchAnchor = fetchAnchorProvider(
indexPatterns,
data.search.searchSource.createEmpty(),
useNewFieldsApi
);
const { fetchSurroundingDocs } = fetchContextProvider(indexPatterns, useNewFieldsApi);
const { setPredecessorCount, setQueryParameters, setSuccessorCount } = getQueryParameterActions(
filterManager,
indexPatterns
);
const setFailedStatus = (state: ContextAppState) => (
subject: keyof LoadingStatusState,
details: LoadingStatusEntry = {}
) =>
(state.loadingStatus[subject] = {
status: LoadingStatus.FAILED,
reason: FailureReason.UNKNOWN,
...details,
});
const setLoadedStatus = (state: ContextAppState) => (subject: keyof LoadingStatusState) =>
(state.loadingStatus[subject] = {
status: LoadingStatus.LOADED,
});
const setLoadingStatus = (state: ContextAppState) => (subject: keyof LoadingStatusState) =>
(state.loadingStatus[subject] = {
status: LoadingStatus.LOADING,
});
const fetchAnchorRow = (state: ContextAppState) => () => {
const {
queryParameters: { indexPatternId, anchorId, sort, tieBreakerField },
} = state;
if (!tieBreakerField) {
return Promise.reject(
setFailedStatus(state)('anchor', {
reason: FailureReason.INVALID_TIEBREAKER,
})
);
}
setLoadingStatus(state)('anchor');
const [[, sortDir]] = sort;
return Promise.try(() =>
fetchAnchor(indexPatternId, anchorId, [fromPairs(sort), { [tieBreakerField]: sortDir }])
).then(
(anchorDocument: EsHitRecord) => {
setLoadedStatus(state)('anchor');
state.rows.anchor = anchorDocument;
return anchorDocument;
},
(error: Error) => {
setFailedStatus(state)('anchor', { error });
getServices().toastNotifications.addDanger({
title: i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', {
defaultMessage: 'Unable to load the anchor document',
}),
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
throw error;
}
);
};
const fetchSurroundingRows = (type: SurrDocType, state: ContextAppState) => {
const {
queryParameters: { indexPatternId, sort, tieBreakerField },
rows: { anchor },
} = state;
const filters = getServices().filterManager.getFilters();
const count =
type === 'successors'
? state.queryParameters.successorCount
: state.queryParameters.predecessorCount;
if (!tieBreakerField) {
return Promise.reject(
setFailedStatus(state)(type, {
reason: FailureReason.INVALID_TIEBREAKER,
})
);
}
setLoadingStatus(state)(type);
const [[sortField, sortDir]] = sort;
return Promise.try(() =>
fetchSurroundingDocs(
type,
indexPatternId,
anchor,
sortField,
tieBreakerField,
sortDir,
count,
filters
)
).then(
(documents: EsHitRecordList) => {
setLoadedStatus(state)(type);
state.rows[type] = documents;
return documents;
},
(error: Error) => {
setFailedStatus(state)(type, { error });
getServices().toastNotifications.addDanger({
title: i18n.translate('discover.context.unableToLoadDocumentDescription', {
defaultMessage: 'Unable to load documents',
}),
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
throw error;
}
);
};
const fetchContextRows = (state: ContextAppState) => () =>
Promise.all([
fetchSurroundingRows('predecessors', state),
fetchSurroundingRows('successors', state),
]);
const fetchAllRows = (state: ContextAppState) => () =>
Promise.try(fetchAnchorRow(state)).then(fetchContextRows(state));
const fetchContextRowsWithNewQueryParameters = (state: ContextAppState) => (
queryParameters: QueryParameters
) => {
setQueryParameters(state)(queryParameters);
return fetchContextRows(state)();
};
const fetchAllRowsWithNewQueryParameters = (state: ContextAppState) => (
queryParameters: QueryParameters
) => {
setQueryParameters(state)(queryParameters);
return fetchAllRows(state)();
};
const fetchGivenPredecessorRows = (state: ContextAppState) => (count: number) => {
setPredecessorCount(state)(count);
return fetchSurroundingRows('predecessors', state);
};
const fetchGivenSuccessorRows = (state: ContextAppState) => (count: number) => {
setSuccessorCount(state)(count);
return fetchSurroundingRows('successors', state);
};
const setAllRows = (state: ContextAppState) => (
predecessorRows: EsHitRecordList,
anchorRow: EsHitRecord,
successorRows: EsHitRecordList
) =>
(state.rows.all = [
...(predecessorRows || []),
...(anchorRow ? [anchorRow] : []),
...(successorRows || []),
]);
return {
fetchAllRows,
fetchAllRowsWithNewQueryParameters,
fetchAnchorRow,
fetchContextRows,
fetchContextRowsWithNewQueryParameters,
fetchGivenPredecessorRows,
fetchGivenSuccessorRows,
setAllRows,
};
}

View file

@ -1,10 +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 { QueryActionsProvider } from './actions';
export { createInitialLoadingStatusState } from './state';

View file

@ -1,17 +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.
*/
import { LoadingStatus, LoadingStatusState } from '../../context_app_state';
export function createInitialLoadingStatusState(): LoadingStatusState {
return {
anchor: LoadingStatus.UNINITIALIZED,
predecessors: LoadingStatus.UNINITIALIZED,
successors: LoadingStatus.UNINITIALIZED,
};
}

View file

@ -1,160 +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.
*/
import { getQueryParameterActions } from './actions';
import { FilterManager, SortDirection } from '../../../../../../data/public';
import { coreMock } from '../../../../../../../core/public/mocks';
import { ContextAppState, LoadingStatus, QueryParameters } from '../../context_app_state';
import { EsHitRecord } from '../api/context';
const setupMock = coreMock.createSetup();
let state: ContextAppState;
let filterManager: FilterManager;
let filterManagerSpy: jest.SpyInstance;
beforeEach(() => {
filterManager = new FilterManager(setupMock.uiSettings);
filterManagerSpy = jest.spyOn(filterManager, 'addFilters');
state = {
queryParameters: {
defaultStepSize: 3,
indexPatternId: 'INDEX_PATTERN_ID',
predecessorCount: 10,
successorCount: 10,
anchorId: '',
columns: [],
filters: [],
sort: [['field', SortDirection.asc]],
tieBreakerField: '',
},
loadingStatus: {
anchor: LoadingStatus.UNINITIALIZED,
predecessors: LoadingStatus.UNINITIALIZED,
successors: LoadingStatus.UNINITIALIZED,
},
rows: {
all: [],
anchor: ({ isAnchor: true, fields: [], sort: [], _id: '' } as unknown) as EsHitRecord,
predecessors: [],
successors: [],
},
useNewFieldsApi: true,
};
});
describe('context query_parameter actions', function () {
describe('action addFilter', () => {
it('should pass the given arguments to the filterManager', () => {
const { addFilter } = getQueryParameterActions(filterManager);
addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION');
// get the generated filter
const generatedFilter = filterManagerSpy.mock.calls[0][0][0];
const queryKeys = Object.keys(generatedFilter.query.match_phrase);
expect(filterManagerSpy.mock.calls.length).toBe(1);
expect(queryKeys[0]).toBe('FIELD_NAME');
expect(generatedFilter.query.match_phrase[queryKeys[0]]).toBe('FIELD_VALUE');
});
it('should pass the index pattern id to the filterManager', () => {
const { addFilter } = getQueryParameterActions(filterManager);
addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION');
const generatedFilter = filterManagerSpy.mock.calls[0][0][0];
expect(generatedFilter.meta.index).toBe('INDEX_PATTERN_ID');
});
});
describe('action setPredecessorCount', () => {
it('should set the predecessorCount to the given value', () => {
const { setPredecessorCount } = getQueryParameterActions(filterManager);
setPredecessorCount(state)(20);
expect(state.queryParameters.predecessorCount).toBe(20);
});
it('should limit the predecessorCount to 0 as a lower bound', () => {
const { setPredecessorCount } = getQueryParameterActions(filterManager);
setPredecessorCount(state)(-1);
expect(state.queryParameters.predecessorCount).toBe(0);
});
it('should limit the predecessorCount to 10000 as an upper bound', () => {
const { setPredecessorCount } = getQueryParameterActions(filterManager);
setPredecessorCount(state)(20000);
expect(state.queryParameters.predecessorCount).toBe(10000);
});
});
describe('action setSuccessorCount', () => {
it('should set the successorCount to the given value', function () {
const { setSuccessorCount } = getQueryParameterActions(filterManager);
setSuccessorCount(state)(20);
expect(state.queryParameters.successorCount).toBe(20);
});
it('should limit the successorCount to 0 as a lower bound', () => {
const { setSuccessorCount } = getQueryParameterActions(filterManager);
setSuccessorCount(state)(-1);
expect(state.queryParameters.successorCount).toBe(0);
});
it('should limit the successorCount to 10000 as an upper bound', () => {
const { setSuccessorCount } = getQueryParameterActions(filterManager);
setSuccessorCount(state)(20000);
expect(state.queryParameters.successorCount).toBe(10000);
});
});
describe('action setQueryParameters', function () {
const { setQueryParameters } = getQueryParameterActions(filterManager);
it('should update the queryParameters with valid properties from the given object', function () {
const newState = {
...state,
queryParameters: {
...state.queryParameters,
additionalParameter: 'ADDITIONAL_PARAMETER',
},
};
const actualState = setQueryParameters(newState)({
anchorId: 'ANCHOR_ID',
columns: ['column'],
defaultStepSize: 3,
filters: [],
indexPatternId: 'INDEX_PATTERN',
predecessorCount: 100,
successorCount: 100,
sort: [['field', SortDirection.asc]],
tieBreakerField: '',
});
expect(actualState).toEqual({
additionalParameter: 'ADDITIONAL_PARAMETER',
anchorId: 'ANCHOR_ID',
columns: ['column'],
defaultStepSize: 3,
filters: [],
indexPatternId: 'INDEX_PATTERN',
predecessorCount: 100,
successorCount: 100,
sort: [['field', SortDirection.asc]],
tieBreakerField: '',
});
});
it('should ignore invalid properties', function () {
const newState = { ...state };
setQueryParameters(newState)(({
additionalParameter: 'ADDITIONAL_PARAMETER',
} as unknown) as QueryParameters);
expect(state.queryParameters).toEqual(newState.queryParameters);
});
});
});

View file

@ -1,82 +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.
*/
import { pick } from 'lodash';
import {
IndexPatternsContract,
FilterManager,
esFilters,
Filter,
IndexPatternField,
} from '../../../../../../data/public';
import { popularizeField } from '../../../helpers/popularize_field';
import { ContextAppState, QueryParameters } from '../../context_app_state';
import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants';
export function getQueryParameterActions(
filterManager: FilterManager,
indexPatterns?: IndexPatternsContract
) {
const setPredecessorCount = (state: ContextAppState) => (predecessorCount: number) => {
return (state.queryParameters.predecessorCount = clamp(
MIN_CONTEXT_SIZE,
MAX_CONTEXT_SIZE,
predecessorCount
));
};
const setSuccessorCount = (state: ContextAppState) => (successorCount: number) => {
return (state.queryParameters.successorCount = clamp(
MIN_CONTEXT_SIZE,
MAX_CONTEXT_SIZE,
successorCount
));
};
const setQueryParameters = (state: ContextAppState) => (queryParameters: QueryParameters) => {
return Object.assign(state.queryParameters, pick(queryParameters, QUERY_PARAMETER_KEYS));
};
const updateFilters = () => (filters: Filter[]) => {
filterManager.setFilters(filters);
};
const addFilter = (state: ContextAppState) => async (
field: IndexPatternField | string,
values: unknown,
operation: string
) => {
const indexPatternId = state.queryParameters.indexPatternId;
const newFilters = esFilters.generateFilters(
filterManager,
field,
values,
operation,
indexPatternId
);
filterManager.addFilters(newFilters);
if (indexPatterns) {
const indexPattern = await indexPatterns.get(indexPatternId);
const fieldName = typeof field === 'string' ? field : field.name;
await popularizeField(indexPattern, fieldName, indexPatterns);
}
};
return {
addFilter,
updateFilters,
setPredecessorCount,
setQueryParameters,
setSuccessorCount,
};
}
function clamp(minimum: number, maximum: number, value: number) {
return Math.max(Math.min(maximum, value), minimum);
}

View file

@ -1,11 +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 { getQueryParameterActions } from './actions';
export { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants';
export { createInitialQueryParametersState } from './state';

View file

@ -1,24 +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 function createInitialQueryParametersState(
defaultStepSize: number = 5,
tieBreakerField: string = '_doc'
) {
return {
anchorId: null,
columns: [],
defaultStepSize,
filters: [],
indexPatternId: null,
predecessorCount: 0,
successorCount: 0,
sort: [],
tieBreakerField,
};
}

View file

@ -1,24 +0,0 @@
<!-- Context App Legacy -->
<context-app-legacy
filter="contextApp.actions.addFilter"
hits="contextApp.state.rows.all"
index-pattern="contextApp.indexPattern"
app-state="contextApp.appState"
state-container="contextApp.stateContainer"
sorting="contextApp.state.queryParameters.sort"
columns="contextApp.state.queryParameters.columns"
minimum-visible-rows="contextApp.state.rows.all.length"
anchor-id="contextApp.anchorId"
anchor-status="contextApp.state.loadingStatus.anchor.status"
anchor-reason="contextApp.state.loadingStatus.anchor.reason"
default-step-size="contextApp.state.queryParameters.defaultStepSize"
predecessor-count="contextApp.state.queryParameters.predecessorCount"
predecessor-available="contextApp.state.rows.predecessors.length"
predecessor-status="contextApp.state.loadingStatus.predecessors.status"
on-change-predecessor-count="contextApp.actions.fetchGivenPredecessorRows"
successor-count="contextApp.state.queryParameters.successorCount"
successor-available="contextApp.state.rows.successors.length"
successor-status="contextApp.state.loadingStatus.successors.status"
on-change-successor-count="contextApp.actions.fetchGivenSuccessorRows"
use-new-fields-api="contextApp.state.useNewFieldsApi"
top-nav-menu="contextApp.topNavMenu"></context-app-legacy>

View file

@ -1,129 +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.
*/
import _ from 'lodash';
import {
CONTEXT_STEP_SETTING,
CONTEXT_TIE_BREAKER_FIELDS_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
} from '../../../common';
import { getAngularModule, getServices } from '../../kibana_services';
import contextAppTemplate from './context_app.html';
import './context/components/action_bar';
import { getFirstSortableField } from './context/api/utils/sorting';
import {
createInitialQueryParametersState,
getQueryParameterActions,
QUERY_PARAMETER_KEYS,
} from './context/query_parameters';
import { createInitialLoadingStatusState, QueryActionsProvider } from './context/query';
import { callAfterBindingsWorkaround } from './context/helpers/call_after_bindings_workaround';
getAngularModule().directive('contextApp', function ContextApp() {
return {
bindToController: true,
controller: callAfterBindingsWorkaround(ContextAppController),
controllerAs: 'contextApp',
restrict: 'E',
scope: {
anchorId: '=',
columns: '=',
indexPattern: '=',
appState: '=',
stateContainer: '=',
filters: '=',
predecessorCount: '=',
successorCount: '=',
sort: '=',
},
template: contextAppTemplate,
};
});
function ContextAppController($scope, Private) {
const { filterManager, indexPatterns, uiSettings, navigation } = getServices();
const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE);
const queryParameterActions = getQueryParameterActions(filterManager, indexPatterns);
const queryActions = Private(QueryActionsProvider);
this.state = createInitialState(
parseInt(uiSettings.get(CONTEXT_STEP_SETTING), 10),
getFirstSortableField(this.indexPattern, uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)),
useNewFieldsApi
);
this.state.useNewFieldsApi = useNewFieldsApi;
this.topNavMenu = navigation.ui.TopNavMenu;
this.actions = _.mapValues(
{
...queryParameterActions,
...queryActions,
},
(action) => (...args) => action(this.state)(...args)
);
$scope.$watchGroup(
[
() => this.state.rows.predecessors,
() => this.state.rows.anchor,
() => this.state.rows.successors,
],
(newValues) => this.actions.setAllRows(...newValues)
);
/**
* Sync properties to state
*/
$scope.$watchCollection(
() => ({
..._.pick(this, QUERY_PARAMETER_KEYS),
indexPatternId: this.indexPattern.id,
}),
(newQueryParameters) => {
const { queryParameters } = this.state;
if (
newQueryParameters.indexPatternId !== queryParameters.indexPatternId ||
newQueryParameters.anchorId !== queryParameters.anchorId ||
!_.isEqual(newQueryParameters.sort, queryParameters.sort)
) {
this.actions.fetchAllRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters));
} else if (
newQueryParameters.predecessorCount !== queryParameters.predecessorCount ||
newQueryParameters.successorCount !== queryParameters.successorCount ||
!_.isEqual(newQueryParameters.filters, queryParameters.filters)
) {
this.actions.fetchContextRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters));
}
}
);
/**
* Sync state to properties
*/
$scope.$watchCollection(
() => ({
predecessorCount: this.state.queryParameters.predecessorCount,
successorCount: this.state.queryParameters.successorCount,
}),
(newParameters) => {
_.assign(this, newParameters);
}
);
}
function createInitialState(defaultStepSize, tieBreakerField, useNewFieldsApi) {
return {
queryParameters: createInitialQueryParametersState(defaultStepSize, tieBreakerField),
rows: {
all: [],
anchor: null,
predecessors: [],
successors: [],
},
loadingStatus: createInitialLoadingStatusState(),
useNewFieldsApi,
};
}

View file

@ -1,60 +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.
*/
import { Filter } from '../../../../data/public';
import { EsHitRecord } from './context/api/context';
import { EsHitRecordList } from './context/api/context';
import { SortDirection } from './context/api/utils/sorting';
export interface ContextAppState {
loadingStatus: LoadingStatusState;
queryParameters: QueryParameters;
rows: ContextRows;
useNewFieldsApi: boolean;
}
export enum LoadingStatus {
FAILED = 'failed',
LOADED = 'loaded',
LOADING = 'loading',
UNINITIALIZED = 'uninitialized',
}
export enum FailureReason {
UNKNOWN = 'unknown',
INVALID_TIEBREAKER = 'invalid_tiebreaker',
}
export type LoadingStatusEntry = Partial<{
status: LoadingStatus;
reason: FailureReason;
error: Error;
}>;
export interface LoadingStatusState {
anchor: LoadingStatusEntry | LoadingStatus;
predecessors: LoadingStatusEntry | LoadingStatus;
successors: LoadingStatusEntry | LoadingStatus;
}
export interface QueryParameters {
anchorId: string;
columns: string[];
defaultStepSize: number;
filters: Filter[];
indexPatternId: string;
predecessorCount: number;
successorCount: number;
sort: Array<[string, SortDirection]>;
tieBreakerField: string;
}
interface ContextRows {
all: EsHitRecordList;
anchor: EsHitRecord;
predecessors: EsHitRecordList;
successors: EsHitRecordList;
}

View file

@ -0,0 +1,64 @@
/*
* 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 { EsHitRecord } from './context/api/context';
import { EsHitRecordList } from './context/api/context';
export interface ContextFetchState {
/**
* Documents listed before anchor
*/
predecessors: EsHitRecordList;
/**
* Documents after anchor
*/
successors: EsHitRecordList;
/**
* Anchor document
*/
anchor: EsHitRecord;
/**
* Anchor fetch status
*/
anchorStatus: LoadingStatusEntry;
/**
* Predecessors fetch status
*/
predecessorsStatus: LoadingStatusEntry;
/**
* Successors fetch status
*/
successorsStatus: LoadingStatusEntry;
}
export enum LoadingStatus {
FAILED = 'failed',
LOADED = 'loaded',
LOADING = 'loading',
UNINITIALIZED = 'uninitialized',
}
export enum FailureReason {
UNKNOWN = 'unknown',
INVALID_TIEBREAKER = 'invalid_tiebreaker',
}
export interface LoadingStatusEntry {
value: LoadingStatus;
error?: Error;
reason?: FailureReason;
}
export const getInitialContextQueryState = (): ContextFetchState => ({
anchor: {} as EsHitRecord,
predecessors: [],
successors: [],
anchorStatus: { value: LoadingStatus.UNINITIALIZED },
predecessorsStatus: { value: LoadingStatus.UNINITIALIZED },
successorsStatus: { value: LoadingStatus.UNINITIALIZED },
});

View file

@ -23,7 +23,7 @@ describe('Test Discover Context State', () => {
history = createBrowserHistory();
history.push('/');
state = getState({
defaultStepSize: '4',
defaultSize: 4,
timeFieldName: 'time',
history,
uiSettings: {

View file

@ -16,7 +16,7 @@ import {
withNotifyOnErrors,
ReduxLikeStateContainer,
} from '../../../../kibana_utils/public';
import { esFilters, FilterManager, Filter, Query } from '../../../../data/public';
import { esFilters, FilterManager, Filter, SortDirection } from '../../../../data/public';
import { handleSourceColumnState } from './helpers';
export interface AppState {
@ -40,7 +40,6 @@ export interface AppState {
* Number of records to be fetched after the anchor records (older records)
*/
successorCount: number;
query?: Query;
}
interface GlobalState {
@ -54,7 +53,7 @@ export interface GetStateParams {
/**
* Number of records to be fetched when 'Load' link/button is clicked
*/
defaultStepSize: string;
defaultSize: number;
/**
* The timefield used for sorting
*/
@ -124,7 +123,7 @@ const APP_STATE_URL_KEY = '_a';
* provides helper functions to start/stop syncing with URL
*/
export function getState({
defaultStepSize,
defaultSize,
timeFieldName,
storeInSessionStorage = false,
history,
@ -142,7 +141,7 @@ export function getState({
const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState;
const appStateInitial = createInitialAppState(
defaultStepSize,
defaultSize,
timeFieldName,
appStateFromUrl,
uiSettings
@ -190,7 +189,7 @@ export function getState({
const mergedState = { ...oldState, ...newState };
if (!isEqualState(oldState, mergedState)) {
appStateContainer.set(mergedState);
stateStorage.set(APP_STATE_URL_KEY, mergedState, { replace: true });
}
},
getFilters: () => [
@ -267,17 +266,17 @@ function getFilters(state: AppState | GlobalState): Filter[] {
* default state. The default size is the default number of successor/predecessor records to fetch
*/
function createInitialAppState(
defaultSize: string,
defaultSize: number,
timeFieldName: string,
urlState: AppState,
uiSettings: IUiSettingsClient
): AppState {
const defaultState = {
const defaultState: AppState = {
columns: ['_source'],
filters: [],
predecessorCount: parseInt(defaultSize, 10),
sort: [[timeFieldName, 'desc']],
successorCount: parseInt(defaultSize, 10),
predecessorCount: defaultSize,
successorCount: defaultSize,
sort: [[timeFieldName, SortDirection.desc]],
};
if (typeof urlState !== 'object') {
return defaultState;

View file

@ -8,4 +8,4 @@
import React from 'react';
export const TopNavMenuMock = () => <div>Hello World</div>;
export const mockTopNavMenu = () => <div>Hello World</div>;

View file

@ -0,0 +1,64 @@
/*
* 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 const mockAnchorHit = {
_id: '123',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T18:52:17.000Z'] },
sort: [1623091937000, 2092],
isAnchor: true,
_version: 1,
};
export const mockPredecessorHits = [
{
_id: '1',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T19:14:29.000Z'] },
sort: ['2021-06-07T19:14:29.000Z', 2092],
_version: 1,
},
{
_id: '2',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T19:14:12.000Z'] },
sort: ['2021-06-07T19:14:12.000Z', 2431],
_version: 1,
},
{
_id: '3',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T19:10:22.000Z'] },
sort: ['2021-06-07T19:10:22.000Z', 2435],
_version: 1,
},
];
export const mockSuccessorHits = [
{
_id: '11',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T18:49:39.000Z'] },
sort: ['2021-06-07T18:49:39.000Z', 2382],
_version: 1,
},
{
_id: '22',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T18:48:28.000Z'] },
sort: ['2021-06-07T18:48:28.000Z', 2631],
_version: 1,
},
{
_id: '33',
_index: 'the-index-pattern-id',
fields: { order_date: ['2021-06-07T18:47:16.000Z'] },
sort: ['2021-06-07T18:47:16.000Z', 2437],
_version: 1,
},
];

View file

@ -0,0 +1,100 @@
/*
* 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 { waitFor } from '@testing-library/react';
import { mountWithIntl } from '@kbn/test/jest';
import { createFilterManagerMock } from '../../../../../data/public/query/filter_manager/filter_manager.mock';
import { mockTopNavMenu } from './__mocks__/top_nav_menu';
import { ContextAppContent } from './context_app_content';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { ContextApp } from './context_app';
import { setServices } from '../../../kibana_services';
import { DiscoverServices } from '../../../build_services';
import { indexPatternsMock } from '../../../__mocks__/index_patterns';
import { act } from 'react-dom/test-utils';
import { uiSettingsMock } from '../../../__mocks__/ui_settings';
const mockFilterManager = createFilterManagerMock();
const mockNavigationPlugin = { ui: { TopNavMenu: mockTopNavMenu } };
describe('ContextApp test', () => {
const defaultProps = {
indexPattern: indexPatternMock,
indexPatternId: 'the-index-pattern-id',
anchorId: 'mocked_anchor_id',
};
const topNavProps = {
appName: 'context',
showSearchBar: true,
showQueryBar: false,
showFilterBar: true,
showSaveQuery: false,
showDatePicker: false,
indexPatterns: [indexPatternMock],
useDefaultBehaviors: true,
};
beforeEach(() => {
setServices(({
data: {
search: {
searchSource: {
createEmpty: jest.fn(),
},
},
},
capabilities: {
discover: {
save: true,
},
},
indexPatterns: indexPatternsMock,
toastNotifications: { addDanger: () => {} },
navigation: mockNavigationPlugin,
core: { notifications: { toasts: [] } },
history: () => {},
filterManager: mockFilterManager,
uiSettings: uiSettingsMock,
} as unknown) as DiscoverServices);
});
it('renders correctly', async () => {
const component = mountWithIntl(<ContextApp {...defaultProps} />);
await waitFor(() => {
expect(component.find(ContextAppContent).length).toBe(1);
const topNavMenu = component.find(mockTopNavMenu);
expect(topNavMenu.length).toBe(1);
expect(topNavMenu.props()).toStrictEqual(topNavProps);
});
});
it('should set filters correctly', async () => {
const component = mountWithIntl(<ContextApp {...defaultProps} />);
await act(async () => {
component.find(ContextAppContent).invoke('addFilter')(
'message',
'2021-06-08T07:52:19.000Z',
'+'
);
});
expect(mockFilterManager.addFilters.mock.calls.length).toBe(1);
expect(mockFilterManager.addFilters.mock.calls[0][0]).toEqual([
{
$state: { store: 'appState' },
meta: { alias: null, disabled: false, index: 'the-index-pattern-id', negate: false },
query: { match_phrase: { message: '2021-06-08T07:52:19.000Z' } },
},
]);
expect(indexPatternsMock.updateSavedObject.mock.calls.length).toBe(1);
expect(indexPatternsMock.updateSavedObject.mock.calls[0]).toEqual([indexPatternMock, 0, true]);
});
});

View file

@ -0,0 +1,178 @@
/*
* 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, { Fragment, memo, useEffect, useRef, useMemo, useCallback } from 'react';
import './context_app.scss';
import classNames from 'classnames';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { EuiText, EuiPageContent, EuiPage, EuiSpacer } from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { esFilters, SortDirection } from '../../../../../data/public';
import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../common';
import { ContextErrorMessage } from '../context_error_message';
import { IndexPattern, IndexPatternField } from '../../../../../data/common';
import { LoadingStatus } from '../../angular/context_query_state';
import { getServices } from '../../../kibana_services';
import { AppState, isEqualFilters } from '../../angular/context_state';
import { useDataGridColumns } from '../../helpers/use_data_grid_columns';
import { useContextAppState } from './use_context_app_state';
import { useContextAppFetch } from './use_context_app_fetch';
import { popularizeField } from '../../helpers/popularize_field';
import { ContextAppContent } from './context_app_content';
import { SurrDocType } from '../../angular/context/api/context';
const ContextAppContentMemoized = memo(ContextAppContent);
export interface ContextAppProps {
indexPattern: IndexPattern;
indexPatternId: string;
anchorId: string;
}
export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAppProps) => {
const services = getServices();
const { uiSettings: config, capabilities, indexPatterns, navigation, filterManager } = services;
const isLegacy = useMemo(() => config.get(DOC_TABLE_LEGACY), [config]);
const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]);
/**
* Context app state
*/
const { appState, setAppState } = useContextAppState({ indexPattern, services });
const prevAppState = useRef<AppState>();
/**
* Context fetched state
*/
const { fetchedState, fetchContextRows, fetchAllRows, fetchSurroundingRows } = useContextAppFetch(
{
anchorId,
indexPatternId,
indexPattern,
appState,
useNewFieldsApi,
services,
}
);
/**
* Fetch docs on ui changes
*/
useEffect(() => {
if (!prevAppState.current) {
fetchAllRows();
} else if (prevAppState.current.predecessorCount !== appState.predecessorCount) {
fetchSurroundingRows(SurrDocType.PREDECESSORS);
} else if (prevAppState.current.successorCount !== appState.successorCount) {
fetchSurroundingRows(SurrDocType.SUCCESSORS);
} else if (!isEqualFilters(prevAppState.current.filters, appState.filters)) {
fetchContextRows();
}
prevAppState.current = cloneDeep(appState);
}, [appState, indexPatternId, anchorId, fetchContextRows, fetchAllRows, fetchSurroundingRows]);
const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useDataGridColumns({
capabilities,
config,
indexPattern,
indexPatterns,
state: appState,
useNewFieldsApi,
setAppState,
});
const rows = useMemo(
() => [
...(fetchedState.predecessors || []),
...(fetchedState.anchor._id ? [fetchedState.anchor] : []),
...(fetchedState.successors || []),
],
[fetchedState.predecessors, fetchedState.anchor, fetchedState.successors]
);
const addFilter = useCallback(
async (field: IndexPatternField | string, values: unknown, operation: string) => {
const newFilters = esFilters.generateFilters(
filterManager,
field,
values,
operation,
indexPatternId
);
filterManager.addFilters(newFilters);
if (indexPatterns) {
const fieldName = typeof field === 'string' ? field : field.name;
await popularizeField(indexPattern, fieldName, indexPatterns);
}
},
[filterManager, indexPatternId, indexPatterns, indexPattern]
);
const TopNavMenu = navigation.ui.TopNavMenu;
const getNavBarProps = () => {
return {
appName: 'context',
showSearchBar: true,
showQueryBar: false,
showFilterBar: true,
showSaveQuery: false,
showDatePicker: false,
indexPatterns: [indexPattern],
useDefaultBehaviors: true,
};
};
return (
<I18nProvider>
{fetchedState.anchorStatus.value === LoadingStatus.FAILED ? (
<ContextErrorMessage status={fetchedState.anchorStatus} />
) : (
<Fragment>
<TopNavMenu {...getNavBarProps()} />
<EuiPage className={classNames({ dscDocsPage: !isLegacy })}>
<EuiPageContent paddingSize="s" className="dscDocsContent">
<EuiSpacer size="s" />
<EuiText>
<strong>
<FormattedMessage
id="discover.context.contextOfTitle"
defaultMessage="Documents surrounding #{anchorId}"
values={{ anchorId }}
/>
</strong>
</EuiText>
<EuiSpacer size="s" />
<ContextAppContentMemoized
services={services}
indexPattern={indexPattern}
useNewFieldsApi={useNewFieldsApi}
isLegacy={isLegacy}
columns={columns}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
sort={appState.sort as [[string, SortDirection]]}
predecessorCount={appState.predecessorCount}
successorCount={appState.successorCount}
setAppState={setAppState}
addFilter={addFilter}
rows={rows}
predecessors={fetchedState.predecessors}
successors={fetchedState.successors}
anchorStatus={fetchedState.anchorStatus.value}
predecessorsStatus={fetchedState.predecessorsStatus.value}
successorsStatus={fetchedState.successorsStatus.value}
/>
</EuiPageContent>
</EuiPage>
</Fragment>
)}
</I18nProvider>
);
};

View file

@ -9,34 +9,27 @@
import React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { uiSettingsMock as mockUiSettings } from '../../../__mocks__/ui_settings';
import { IndexPattern } from '../../../../../data/common/index_patterns';
import { ContextAppLegacy } from './context_app_legacy';
import { DocTableLegacy } from '../../angular/doc_table/create_doc_table_react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { ActionBar } from '../../angular/context/components/action_bar/action_bar';
import { ContextErrorMessage } from '../context_error_message';
import { TopNavMenuMock } from './__mocks__/top_nav_menu';
import { AppState, GetStateReturn } from '../../angular/context_state';
import { SortDirection } from 'src/plugins/data/common';
import { EsHitRecordList } from '../../angular/context/api/context';
import { ContextAppContent, ContextAppContentProps } from './context_app_content';
import { getServices } from '../../../kibana_services';
import { LoadingStatus } from '../../angular/context_query_state';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { DiscoverGrid } from '../discover_grid/discover_grid';
jest.mock('../../../kibana_services', () => {
return {
getServices: () => ({
metadata: {
branch: 'test',
},
capabilities: {
discover: {
save: true,
},
},
uiSettings: mockUiSettings,
}),
};
});
describe('ContextAppLegacy test', () => {
describe('ContextAppContent test', () => {
const hit = {
_id: '123',
_index: 'test_index',
@ -53,75 +46,59 @@ describe('ContextAppLegacy test', () => {
fields: [{ order_date: ['2020-10-19T13:35:02.000Z'] }],
sort: [1603114502000, 2092],
};
const indexPattern = {
id: 'test_index_pattern',
} as IndexPattern;
const defaultProps = {
columns: ['_source'],
filter: () => {},
hits: ([hit] as unknown) as EsHitRecordList,
sorting: [['order_date', 'desc']] as Array<[string, SortDirection]>,
minimumVisibleRows: 5,
indexPattern,
const defaultProps = ({
columns: ['Time (@timestamp)', '_source'],
indexPattern: indexPatternMock,
appState: ({} as unknown) as AppState,
stateContainer: ({} as unknown) as GetStateReturn,
anchorId: 'test_anchor_id',
anchorStatus: 'loaded',
anchorReason: 'no reason',
anchorStatus: LoadingStatus.LOADED,
predecessorsStatus: LoadingStatus.LOADED,
successorsStatus: LoadingStatus.LOADED,
rows: ([hit] as unknown) as EsHitRecordList,
predecessors: [],
successors: [],
defaultStepSize: 5,
predecessorCount: 10,
successorCount: 10,
predecessorAvailable: 10,
successorAvailable: 10,
onChangePredecessorCount: jest.fn(),
onChangeSuccessorCount: jest.fn(),
predecessorStatus: 'loaded',
successorStatus: 'loaded',
topNavMenu: TopNavMenuMock,
useNewFieldsApi: false,
isPaginationEnabled: false,
};
const topNavProps = {
appName: 'context',
showSearchBar: true,
showQueryBar: false,
showFilterBar: true,
showSaveQuery: false,
showDatePicker: false,
indexPatterns: [indexPattern],
useDefaultBehaviors: true,
};
onAddColumn: () => {},
onRemoveColumn: () => {},
onSetColumns: () => {},
services: getServices(),
sort: [['order_date', 'desc']] as Array<[string, SortDirection]>,
isLegacy: true,
setAppState: () => {},
addFilter: () => {},
} as unknown) as ContextAppContentProps;
it('renders correctly', () => {
const component = mountWithIntl(<ContextAppLegacy {...defaultProps} />);
it('should render legacy table correctly', () => {
const component = mountWithIntl(<ContextAppContent {...defaultProps} />);
expect(component.find(DocTableLegacy).length).toBe(1);
const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator');
expect(loadingIndicator.length).toBe(0);
expect(component.find(ActionBar).length).toBe(2);
const topNavMenu = component.find(TopNavMenuMock);
expect(topNavMenu.length).toBe(1);
expect(topNavMenu.props()).toStrictEqual(topNavProps);
});
it('renders loading indicator', () => {
const props = { ...defaultProps };
props.anchorStatus = 'loading';
const component = mountWithIntl(<ContextAppLegacy {...props} />);
props.anchorStatus = LoadingStatus.LOADING;
const component = mountWithIntl(<ContextAppContent {...props} />);
expect(component.find(DocTableLegacy).length).toBe(0);
const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator');
expect(loadingIndicator.length).toBe(1);
expect(component.find(ActionBar).length).toBe(2);
expect(component.find(TopNavMenuMock).length).toBe(1);
});
it('renders error message', () => {
const props = { ...defaultProps };
props.anchorStatus = 'failed';
props.anchorReason = 'something went wrong';
const component = mountWithIntl(<ContextAppLegacy {...props} />);
props.anchorStatus = LoadingStatus.FAILED;
const component = mountWithIntl(<ContextAppContent {...props} />);
expect(component.find(DocTableLegacy).length).toBe(0);
expect(component.find(TopNavMenuMock).length).toBe(0);
const errorMessage = component.find(ContextErrorMessage);
expect(errorMessage.length).toBe(1);
});
it('should render discover grid correctly', () => {
const props = { ...defaultProps, isLegacy: false };
const component = mountWithIntl(<ContextAppContent {...props} />);
expect(component.find(DiscoverGrid).length).toBe(1);
});
});

View file

@ -0,0 +1,199 @@
/*
* 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, { useState, Fragment, useMemo, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiHorizontalRule, EuiText } from '@elastic/eui';
import { CONTEXT_STEP_SETTING, DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../common';
import { IndexPattern, IndexPatternField } from '../../../../../data/common';
import { SortDirection } from '../../../../../data/public';
import {
DocTableLegacy,
DocTableLegacyProps,
} from '../../angular/doc_table/create_doc_table_react';
import { LoadingStatus } from '../../angular/context_query_state';
import { ActionBar } from '../../angular/context/components/action_bar/action_bar';
import { DiscoverGrid, DiscoverGridProps } from '../discover_grid/discover_grid';
import { ElasticSearchHit } from '../../doc_views/doc_views_types';
import { AppState } from '../../angular/context_state';
import { EsHitRecord, EsHitRecordList, SurrDocType } from '../../angular/context/api/context';
import { DiscoverServices } from '../../../build_services';
import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from './utils/constants';
export interface ContextAppContentProps {
columns: string[];
onAddColumn: (columnsName: string) => void;
onRemoveColumn: (columnsName: string) => void;
onSetColumns: (columnsNames: string[]) => void;
services: DiscoverServices;
indexPattern: IndexPattern;
predecessorCount: number;
successorCount: number;
rows: EsHitRecordList;
sort: [[string, SortDirection]];
predecessors: EsHitRecordList;
successors: EsHitRecordList;
anchorStatus: LoadingStatus;
predecessorsStatus: LoadingStatus;
successorsStatus: LoadingStatus;
useNewFieldsApi: boolean;
isLegacy: boolean;
setAppState: (newState: Partial<AppState>) => void;
addFilter: (
field: IndexPatternField | string,
values: unknown,
operation: string
) => Promise<void>;
}
const controlColumnIds = ['openDetails'];
export function clamp(value: number) {
return Math.max(Math.min(MAX_CONTEXT_SIZE, value), MIN_CONTEXT_SIZE);
}
const DataGridMemoized = React.memo(DiscoverGrid);
const DocTableLegacyMemoized = React.memo(DocTableLegacy);
const ActionBarMemoized = React.memo(ActionBar);
export function ContextAppContent({
columns,
onAddColumn,
onRemoveColumn,
onSetColumns,
services,
indexPattern,
predecessorCount,
successorCount,
rows,
sort,
predecessors,
successors,
anchorStatus,
predecessorsStatus,
successorsStatus,
useNewFieldsApi,
isLegacy,
setAppState,
addFilter,
}: ContextAppContentProps) {
const { uiSettings: config } = services;
const [expandedDoc, setExpandedDoc] = useState<EsHitRecord | undefined>(undefined);
const isAnchorLoaded = anchorStatus === LoadingStatus.LOADED;
const isAnchorLoading =
anchorStatus === LoadingStatus.LOADING || anchorStatus === LoadingStatus.UNINITIALIZED;
const arePredecessorsLoading =
predecessorsStatus === LoadingStatus.LOADING ||
predecessorsStatus === LoadingStatus.UNINITIALIZED;
const areSuccessorsLoading =
successorsStatus === LoadingStatus.LOADING || successorsStatus === LoadingStatus.UNINITIALIZED;
const showTimeCol = useMemo(
() => !config.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName,
[config, indexPattern]
);
const defaultStepSize = useMemo(() => parseInt(config.get(CONTEXT_STEP_SETTING), 10), [config]);
const docTableProps = () => {
return {
ariaLabelledBy: 'surDocumentsAriaLabel',
columns,
rows: rows as ElasticSearchHit[],
indexPattern,
expandedDoc,
isLoading: isAnchorLoading,
sampleSize: 0,
sort: sort as [[string, SortDirection]],
isSortEnabled: false,
showTimeCol,
services,
useNewFieldsApi,
isPaginationEnabled: false,
controlColumnIds,
setExpandedDoc,
onFilter: addFilter,
onAddColumn,
onRemoveColumn,
onSetColumns,
} as DiscoverGridProps;
};
const legacyDocTableProps = () => {
// @ts-expect-error doesn't implement full DocTableLegacyProps interface
return {
columns,
indexPattern,
minimumVisibleRows: rows.length,
rows,
onFilter: addFilter,
onAddColumn,
onRemoveColumn,
sort,
useNewFieldsApi,
} as DocTableLegacyProps;
};
const loadingFeedback = () => {
if (
isLegacy &&
(anchorStatus === LoadingStatus.UNINITIALIZED || anchorStatus === LoadingStatus.LOADING)
) {
return (
<EuiText textAlign="center" data-test-subj="contextApp_loadingIndicator">
<FormattedMessage id="discover.context.loadingDescription" defaultMessage="Loading..." />
</EuiText>
);
}
return null;
};
const onChangeCount = useCallback(
(type: SurrDocType, count: number) => {
const countKey = type === SurrDocType.SUCCESSORS ? 'successorCount' : 'predecessorCount';
setAppState({ [countKey]: clamp(count) });
},
[setAppState]
);
return (
<Fragment>
<ActionBarMemoized
type={SurrDocType.PREDECESSORS}
defaultStepSize={defaultStepSize}
docCount={predecessorCount}
docCountAvailable={predecessors.length}
onChangeCount={onChangeCount}
isLoading={arePredecessorsLoading}
isDisabled={!isAnchorLoaded}
/>
{loadingFeedback()}
<EuiHorizontalRule margin="xs" />
{isLegacy && isAnchorLoaded && (
<div className="discover-table">
<DocTableLegacyMemoized {...legacyDocTableProps()} />
</div>
)}
{!isLegacy && (
<div className="dscDocsGrid">
<DataGridMemoized {...docTableProps()} />
</div>
)}
<EuiHorizontalRule margin="xs" />
<ActionBarMemoized
type={SurrDocType.SUCCESSORS}
defaultStepSize={defaultStepSize}
docCount={successorCount}
docCountAvailable={successors.length}
onChangeCount={onChangeCount}
isLoading={areSuccessorsLoading}
isDisabled={!isAnchorLoaded}
/>
</Fragment>
);
}

View file

@ -1,224 +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.
*/
import React, { useState, Fragment } from 'react';
import classNames from 'classnames';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import './context_app_legacy.scss';
import { EuiHorizontalRule, EuiText, EuiPageContent, EuiPage, EuiSpacer } from '@elastic/eui';
import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY } from '../../../../common';
import { ContextErrorMessage } from '../context_error_message';
import {
DocTableLegacy,
DocTableLegacyProps,
} from '../../angular/doc_table/create_doc_table_react';
import { IndexPattern } from '../../../../../data/common/index_patterns';
import { LoadingStatus } from '../../angular/context_app_state';
import { ActionBar, ActionBarProps } from '../../angular/context/components/action_bar/action_bar';
import { TopNavMenuProps } from '../../../../../navigation/public';
import { DiscoverGrid, DiscoverGridProps } from '../discover_grid/discover_grid';
import { DocViewFilterFn } from '../../doc_views/doc_views_types';
import { getServices, SortDirection } from '../../../kibana_services';
import { GetStateReturn, AppState } from '../../angular/context_state';
import { useDataGridColumns } from '../../helpers/use_data_grid_columns';
import { EsHitRecord, EsHitRecordList } from '../../angular/context/api/context';
export interface ContextAppProps {
topNavMenu: React.ComponentType<TopNavMenuProps>;
columns: string[];
hits: EsHitRecordList;
indexPattern: IndexPattern;
appState: AppState;
stateContainer: GetStateReturn;
filter: DocViewFilterFn;
minimumVisibleRows: number;
sorting: Array<[string, SortDirection]>;
anchorId: string;
anchorStatus: string;
anchorReason: string;
predecessorStatus: string;
successorStatus: string;
defaultStepSize: number;
predecessorCount: number;
successorCount: number;
predecessorAvailable: number;
successorAvailable: number;
onChangePredecessorCount: (count: number) => void;
onChangeSuccessorCount: (count: number) => void;
useNewFieldsApi?: boolean;
}
const DataGridMemoized = React.memo(DiscoverGrid);
const PREDECESSOR_TYPE = 'predecessors';
const SUCCESSOR_TYPE = 'successors';
function isLoading(status: string) {
return status !== LoadingStatus.LOADED && status !== LoadingStatus.FAILED;
}
export function ContextAppLegacy(renderProps: ContextAppProps) {
const services = getServices();
const { uiSettings: config, capabilities, indexPatterns } = services;
const {
indexPattern,
anchorId,
anchorStatus,
predecessorStatus,
successorStatus,
appState,
stateContainer,
hits: rows,
sorting,
filter,
minimumVisibleRows,
useNewFieldsApi,
} = renderProps;
const [expandedDoc, setExpandedDoc] = useState<EsHitRecord | undefined>(undefined);
const isAnchorLoaded = anchorStatus === LoadingStatus.LOADED;
const isFailed = anchorStatus === LoadingStatus.FAILED;
const isLegacy = config.get(DOC_TABLE_LEGACY);
const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useDataGridColumns({
capabilities,
config,
indexPattern,
indexPatterns,
setAppState: stateContainer.setAppState,
state: appState,
useNewFieldsApi: !!useNewFieldsApi,
});
const actionBarProps = (type: string) => {
const {
defaultStepSize,
successorCount,
predecessorCount,
predecessorAvailable,
successorAvailable,
onChangePredecessorCount,
onChangeSuccessorCount,
} = renderProps;
const isPredecessorType = type === PREDECESSOR_TYPE;
return {
defaultStepSize,
docCount: isPredecessorType ? predecessorCount : successorCount,
docCountAvailable: isPredecessorType ? predecessorAvailable : successorAvailable,
onChangeCount: isPredecessorType ? onChangePredecessorCount : onChangeSuccessorCount,
isLoading: isPredecessorType ? isLoading(predecessorStatus) : isLoading(successorStatus),
type,
isDisabled: !isAnchorLoaded,
} as ActionBarProps;
};
const docTableProps = () => {
return {
ariaLabelledBy: 'surDocumentsAriaLabel',
columns,
rows,
indexPattern,
expandedDoc,
isLoading: isLoading(anchorStatus),
sampleSize: 0,
sort: sorting,
isSortEnabled: false,
showTimeCol: !config.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName,
services,
useNewFieldsApi,
isPaginationEnabled: false,
controlColumnIds: ['openDetails'],
setExpandedDoc,
onFilter: filter,
onAddColumn,
onRemoveColumn,
onSetColumns,
} as DiscoverGridProps;
};
const legacyDocTableProps = () => {
// @ts-expect-error doesn't implement full DocTableLegacyProps interface
return {
columns,
indexPattern,
minimumVisibleRows,
rows,
onFilter: filter,
onAddColumn,
onRemoveColumn,
sort: sorting.map((el) => [el]),
useNewFieldsApi,
} as DocTableLegacyProps;
};
const TopNavMenu = renderProps.topNavMenu;
const getNavBarProps = () => {
return {
appName: 'context',
showSearchBar: true,
showQueryBar: false,
showFilterBar: true,
showSaveQuery: false,
showDatePicker: false,
indexPatterns: [renderProps.indexPattern],
useDefaultBehaviors: true,
};
};
const loadingFeedback = () => {
if (anchorStatus === LoadingStatus.UNINITIALIZED || anchorStatus === LoadingStatus.LOADING) {
return (
<EuiText textAlign="center" data-test-subj="contextApp_loadingIndicator">
<FormattedMessage id="discover.context.loadingDescription" defaultMessage="Loading..." />
</EuiText>
);
}
return null;
};
return (
<I18nProvider>
{isFailed ? (
<ContextErrorMessage status={anchorStatus} reason={renderProps.anchorReason} />
) : (
<Fragment>
<TopNavMenu {...getNavBarProps()} />
<EuiPage className={classNames({ dscDocsPage: !isLegacy })}>
<EuiPageContent paddingSize="s" className="dscDocsContent">
<EuiSpacer size="s" />
<EuiText>
<strong>
<FormattedMessage
id="discover.context.contextOfTitle"
defaultMessage="Documents surrounding #{anchorId}"
values={{ anchorId }}
/>
</strong>
</EuiText>
<EuiSpacer size="s" />
<ActionBar {...actionBarProps(PREDECESSOR_TYPE)} />
{isLegacy && loadingFeedback()}
<EuiHorizontalRule margin="xs" />
{isLegacy ? (
isAnchorLoaded && (
<div className="discover-table">
<DocTableLegacy {...legacyDocTableProps()} />
</div>
)
) : (
<div className="dscDocsGrid">
<DataGridMemoized {...docTableProps()} />
</div>
)}
<EuiHorizontalRule margin="xs" />
<ActionBar {...actionBarProps(SUCCESSOR_TYPE)} />
</EuiPageContent>
</EuiPage>
</Fragment>
)}
</I18nProvider>
);
}

View file

@ -6,32 +6,13 @@
* Side Public License, v 1.
*/
import { ContextAppLegacy } from './context_app_legacy';
import { ContextApp } from './context_app';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createContextAppLegacy(reactDirective: any) {
return reactDirective(ContextAppLegacy, [
['filter', { watchDepth: 'reference' }],
['hits', { watchDepth: 'reference' }],
return reactDirective(ContextApp, [
['indexPattern', { watchDepth: 'reference' }],
['appState', { watchDepth: 'reference' }],
['stateContainer', { watchDepth: 'reference' }],
['sorting', { watchDepth: 'reference' }],
['columns', { watchDepth: 'collection' }],
['minimumVisibleRows', { watchDepth: 'reference' }],
['indexPatternId', { watchDepth: 'reference' }],
['anchorId', { watchDepth: 'reference' }],
['anchorStatus', { watchDepth: 'reference' }],
['anchorReason', { watchDepth: 'reference' }],
['defaultStepSize', { watchDepth: 'reference' }],
['predecessorCount', { watchDepth: 'reference' }],
['predecessorAvailable', { watchDepth: 'reference' }],
['predecessorStatus', { watchDepth: 'reference' }],
['onChangePredecessorCount', { watchDepth: 'reference' }],
['successorCount', { watchDepth: 'reference' }],
['successorAvailable', { watchDepth: 'reference' }],
['successorStatus', { watchDepth: 'reference' }],
['onChangeSuccessorCount', { watchDepth: 'reference' }],
['useNewFieldsApi', { watchDepth: 'reference' }],
['topNavMenu', { watchDepth: 'reference' }],
]);
}

View file

@ -0,0 +1,200 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { setServices, getServices } from '../../../kibana_services';
import { SortDirection } from '../../../../../data/public';
import { createFilterManagerMock } from '../../../../../data/public/query/filter_manager/filter_manager.mock';
import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../common';
import { DiscoverServices } from '../../../build_services';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { indexPatternsMock } from '../../../__mocks__/index_patterns';
import { FailureReason, LoadingStatus } from '../../angular/context_query_state';
import { ContextAppFetchProps, useContextAppFetch } from './use_context_app_fetch';
import {
mockAnchorHit,
mockPredecessorHits,
mockSuccessorHits,
} from './__mocks__/use_context_app_fetch';
const mockFilterManager = createFilterManagerMock();
jest.mock('../../angular/context/api/context', () => {
const originalModule = jest.requireActual('../../angular/context/api/context');
return {
...originalModule,
fetchContextProvider: () => ({
fetchSurroundingDocs: (type: string, indexPatternId: string) => {
if (!indexPatternId) {
throw new Error();
}
return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits;
},
}),
};
});
jest.mock('../../angular/context/api/anchor', () => ({
fetchAnchorProvider: () => (indexPatternId: string) => {
if (!indexPatternId) {
throw new Error();
}
return mockAnchorHit;
},
}));
const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pattern-id') => {
const dangerNotification = jest.fn();
setServices(({
data: {
search: {
searchSource: {
createEmpty: jest.fn(),
},
},
},
indexPatterns: indexPatternsMock,
toastNotifications: { addDanger: dangerNotification },
core: { notifications: { toasts: [] } },
history: () => {},
filterManager: mockFilterManager,
uiSettings: {
get: (key: string) => {
if (key === CONTEXT_TIE_BREAKER_FIELDS_SETTING) {
return tieBreakerFields;
}
},
},
} as unknown) as DiscoverServices);
return {
dangerNotification,
props: ({
anchorId: 'mock_anchor_id',
indexPatternId,
indexPattern: indexPatternMock,
appState: {
sort: [['order_date', SortDirection.desc]],
predecessorCount: 2,
successorCount: 2,
},
useNewFieldsApi: false,
services: getServices(),
} as unknown) as ContextAppFetchProps,
};
};
describe('test useContextAppFetch', () => {
it('should fetch all correctly', async () => {
const { props } = initDefaults(['_doc']);
const { result } = renderHook(() => {
return useContextAppFetch(props);
});
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.UNINITIALIZED);
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
await act(async () => {
await result.current.fetchAllRows();
});
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.LOADED);
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.LOADED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.LOADED);
expect(result.current.fetchedState.anchor).toEqual({ ...mockAnchorHit, isAnchor: true });
expect(result.current.fetchedState.predecessors).toEqual(mockPredecessorHits);
expect(result.current.fetchedState.successors).toEqual(mockSuccessorHits);
});
it('should set anchorStatus to failed when tieBreakingField array is empty', async () => {
const { props } = initDefaults([]);
const { result } = renderHook(() => {
return useContextAppFetch(props);
});
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.UNINITIALIZED);
await act(async () => {
await result.current.fetchAllRows();
});
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.FAILED);
expect(result.current.fetchedState.anchorStatus.reason).toBe(FailureReason.INVALID_TIEBREAKER);
expect(result.current.fetchedState.anchor).toEqual({});
expect(result.current.fetchedState.predecessors).toEqual([]);
expect(result.current.fetchedState.successors).toEqual([]);
});
it('should set anchorStatus to failed when invalid indexPatternId provided', async () => {
const { props, dangerNotification } = initDefaults(['_doc'], '');
const { result } = renderHook(() => {
return useContextAppFetch(props);
});
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.UNINITIALIZED);
await act(async () => {
await result.current.fetchAllRows();
});
expect(dangerNotification.mock.calls.length).toBe(1);
expect(result.current.fetchedState.anchorStatus.value).toBe(LoadingStatus.FAILED);
expect(result.current.fetchedState.anchorStatus.reason).toBe(FailureReason.UNKNOWN);
expect(result.current.fetchedState.anchor).toEqual({});
expect(result.current.fetchedState.predecessors).toEqual([]);
expect(result.current.fetchedState.successors).toEqual([]);
});
it('should fetch context rows correctly', async () => {
const { props } = initDefaults(['_doc']);
const { result } = renderHook(() => {
return useContextAppFetch(props);
});
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
await act(async () => {
await result.current.fetchContextRows(mockAnchorHit);
});
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.LOADED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.LOADED);
expect(result.current.fetchedState.predecessors).toEqual(mockPredecessorHits);
expect(result.current.fetchedState.successors).toEqual(mockSuccessorHits);
});
it('should set context rows statuses to failed when invalid indexPatternId provided', async () => {
const { props, dangerNotification } = initDefaults(['_doc'], '');
const { result } = renderHook(() => {
return useContextAppFetch(props);
});
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.UNINITIALIZED);
await act(async () => {
await result.current.fetchContextRows(mockAnchorHit);
});
expect(dangerNotification.mock.calls.length).toBe(2); // for successors and predecessors
expect(result.current.fetchedState.predecessorsStatus.value).toBe(LoadingStatus.FAILED);
expect(result.current.fetchedState.successorsStatus.value).toBe(LoadingStatus.FAILED);
expect(result.current.fetchedState.successorsStatus.reason).toBe(FailureReason.UNKNOWN);
expect(result.current.fetchedState.successorsStatus.reason).toBe(FailureReason.UNKNOWN);
expect(result.current.fetchedState.predecessors).toEqual([]);
expect(result.current.fetchedState.successors).toEqual([]);
});
});

View file

@ -0,0 +1,186 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { fromPairs } from 'lodash';
import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../common';
import { DiscoverServices } from '../../../build_services';
import { fetchAnchorProvider } from '../../angular/context/api/anchor';
import { EsHitRecord, fetchContextProvider, SurrDocType } from '../../angular/context/api/context';
import { MarkdownSimple, toMountPoint } from '../../../../../kibana_react/public';
import { IndexPattern, SortDirection } from '../../../../../data/public';
import {
ContextFetchState,
FailureReason,
getInitialContextQueryState,
LoadingStatus,
} from '../../angular/context_query_state';
import { AppState } from '../../angular/context_state';
import { getFirstSortableField } from '../../angular/context/api/utils/sorting';
const createError = (statusKey: string, reason: FailureReason, error?: Error) => ({
[statusKey]: { value: LoadingStatus.FAILED, error, reason },
});
export interface ContextAppFetchProps {
anchorId: string;
indexPatternId: string;
indexPattern: IndexPattern;
appState: AppState;
useNewFieldsApi: boolean;
services: DiscoverServices;
}
export function useContextAppFetch({
anchorId,
indexPatternId,
indexPattern,
appState,
useNewFieldsApi,
services,
}: ContextAppFetchProps) {
const { uiSettings: config, data, indexPatterns, toastNotifications, filterManager } = services;
const searchSource = useMemo(() => {
return data.search.searchSource.createEmpty();
}, [data.search.searchSource]);
const tieBreakerField = useMemo(
() => getFirstSortableField(indexPattern, config.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)),
[config, indexPattern]
);
const fetchAnchor = useMemo(() => {
return fetchAnchorProvider(indexPatterns, searchSource, useNewFieldsApi);
}, [indexPatterns, searchSource, useNewFieldsApi]);
const { fetchSurroundingDocs } = useMemo(
() => fetchContextProvider(indexPatterns, useNewFieldsApi),
[indexPatterns, useNewFieldsApi]
);
const [fetchedState, setFetchedState] = useState<ContextFetchState>(
getInitialContextQueryState()
);
const setState = useCallback((values: Partial<ContextFetchState>) => {
setFetchedState((prevState) => ({ ...prevState, ...values }));
}, []);
const fetchAnchorRow = useCallback(async () => {
const { sort } = appState;
const [[, sortDir]] = sort;
const errorTitle = i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', {
defaultMessage: 'Unable to load the anchor document',
});
if (!tieBreakerField) {
setState(createError('anchorStatus', FailureReason.INVALID_TIEBREAKER));
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(
<MarkdownSimple>
{i18n.translate('discover.context.invalidTieBreakerFiledSetting', {
defaultMessage: 'Invalid tie breaker field setting',
})}
</MarkdownSimple>
),
});
return;
}
try {
setState({ anchorStatus: { value: LoadingStatus.LOADING } });
const anchor = await fetchAnchor(indexPatternId, anchorId, [
fromPairs(sort),
{ [tieBreakerField]: sortDir },
]);
setState({ anchor, anchorStatus: { value: LoadingStatus.LOADED } });
return anchor;
} catch (error) {
setState(createError('anchorStatus', FailureReason.UNKNOWN, error));
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
}
}, [
appState,
tieBreakerField,
setState,
toastNotifications,
fetchAnchor,
indexPatternId,
anchorId,
]);
const fetchSurroundingRows = useCallback(
async (type: SurrDocType, fetchedAnchor?: EsHitRecord) => {
const filters = filterManager.getFilters();
const { sort } = appState;
const [[sortField, sortDir]] = sort;
const count =
type === SurrDocType.PREDECESSORS ? appState.predecessorCount : appState.successorCount;
const anchor = fetchedAnchor || fetchedState.anchor;
const statusKey = `${type}Status`;
const errorTitle = i18n.translate('discover.context.unableToLoadDocumentDescription', {
defaultMessage: 'Unable to load documents',
});
try {
setState({ [statusKey]: { value: LoadingStatus.LOADING } });
const rows = await fetchSurroundingDocs(
type,
indexPatternId,
anchor as EsHitRecord,
sortField,
tieBreakerField,
sortDir as SortDirection,
count,
filters
);
setState({ [type]: rows, [statusKey]: { value: LoadingStatus.LOADED } });
} catch (error) {
setState(createError(statusKey, FailureReason.UNKNOWN, error));
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
}
},
[
filterManager,
appState,
fetchedState.anchor,
tieBreakerField,
setState,
fetchSurroundingDocs,
indexPatternId,
toastNotifications,
]
);
const fetchContextRows = useCallback(
(anchor?: EsHitRecord) =>
Promise.allSettled([
fetchSurroundingRows(SurrDocType.PREDECESSORS, anchor),
fetchSurroundingRows(SurrDocType.SUCCESSORS, anchor),
]),
[fetchSurroundingRows]
);
const fetchAllRows = useCallback(
() => fetchAnchorRow().then((anchor) => anchor && fetchContextRows(anchor)),
[fetchAnchorRow, fetchContextRows]
);
return {
fetchedState,
fetchAllRows,
fetchContextRows,
fetchSurroundingRows,
};
}

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect, useMemo, useState } from 'react';
import { cloneDeep } from 'lodash';
import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../../common';
import { IndexPattern } from '../../../../../data/public';
import { DiscoverServices } from '../../../build_services';
import { AppState, getState } from '../../angular/context_state';
export function useContextAppState({
indexPattern,
services,
}: {
indexPattern: IndexPattern;
services: DiscoverServices;
}) {
const { uiSettings: config, history, core, filterManager } = services;
const stateContainer = useMemo(() => {
return getState({
defaultSize: parseInt(config.get(CONTEXT_DEFAULT_SIZE_SETTING), 10),
timeFieldName: indexPattern.timeFieldName!,
storeInSessionStorage: config.get('state:storeInSessionStorage'),
history: history(),
toasts: core.notifications.toasts,
uiSettings: config,
});
}, [config, history, indexPattern, core.notifications.toasts]);
const [appState, setState] = useState<AppState>(stateContainer.appState.getState());
/**
* Sync with app state container
*/
useEffect(() => {
stateContainer.startSync();
return () => stateContainer.stopSync();
}, [stateContainer]);
useEffect(() => {
const unsubscribeAppState = stateContainer.appState.subscribe((newState) => {
setState((prevState) => ({ ...prevState, ...newState }));
});
return () => unsubscribeAppState();
}, [stateContainer, setState]);
/**
* Take care of filters
*/
useEffect(() => {
const filters = stateContainer.appState.getState().filters;
if (filters) {
filterManager.setAppFilters(cloneDeep(filters));
}
const { setFilters } = stateContainer;
const filterObservable = filterManager.getUpdates$().subscribe(() => {
setFilters(filterManager);
});
return () => filterObservable.unsubscribe();
}, [filterManager, stateContainer]);
return {
appState,
stateContainer,
setAppState: stateContainer.setAppState,
};
}

View file

@ -6,8 +6,5 @@
* Side Public License, v 1.
*/
import { createInitialQueryParametersState } from './state';
export const MAX_CONTEXT_SIZE = 10000; // Elasticsearch's default maximum size limit
export const MIN_CONTEXT_SIZE = 0;
export const QUERY_PARAMETER_KEYS = Object.keys(createInitialQueryParametersState());

View file

@ -10,31 +10,33 @@ import React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme';
import { ContextErrorMessage } from './context_error_message';
import { FailureReason, LoadingStatus } from '../../angular/context_app_state';
import { FailureReason, LoadingStatus } from '../../angular/context_query_state';
import { findTestSubject } from '@elastic/eui/lib/test';
describe('loading spinner', function () {
let component: ReactWrapper;
it('ContextErrorMessage does not render on loading', () => {
component = mountWithIntl(<ContextErrorMessage status={LoadingStatus.LOADING} />);
component = mountWithIntl(<ContextErrorMessage status={{ value: LoadingStatus.LOADING }} />);
expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0);
});
it('ContextErrorMessage does not render on success loading', () => {
component = mountWithIntl(<ContextErrorMessage status={LoadingStatus.LOADED} />);
component = mountWithIntl(<ContextErrorMessage status={{ value: LoadingStatus.LOADED }} />);
expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0);
});
it('ContextErrorMessage renders just the title if the reason is not specifically handled', () => {
component = mountWithIntl(<ContextErrorMessage status={LoadingStatus.FAILED} />);
component = mountWithIntl(<ContextErrorMessage status={{ value: LoadingStatus.FAILED }} />);
expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1);
expect(findTestSubject(component, 'contextErrorMessageBody').text()).toBe('');
});
it('ContextErrorMessage renders the reason for unknown errors', () => {
component = mountWithIntl(
<ContextErrorMessage status={LoadingStatus.FAILED} reason={FailureReason.UNKNOWN} />
<ContextErrorMessage
status={{ value: LoadingStatus.FAILED, reason: FailureReason.UNKNOWN }}
/>
);
expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1);
expect(findTestSubject(component, 'contextErrorMessageBody').length).toBe(1);

View file

@ -9,21 +9,21 @@
import React from 'react';
import { EuiCallOut, EuiText } from '@elastic/eui';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { FailureReason, LoadingStatus } from '../../angular/context_app_state';
import {
FailureReason,
LoadingStatus,
LoadingStatusEntry,
} from '../../angular/context_query_state';
export interface ContextErrorMessageProps {
/**
* the status of the loading action
*/
status: string;
/**
* the reason of the error
*/
reason?: string;
status: LoadingStatusEntry;
}
export function ContextErrorMessage({ status, reason }: ContextErrorMessageProps) {
if (status !== LoadingStatus.FAILED) {
export function ContextErrorMessage({ status }: ContextErrorMessageProps) {
if (status.value !== LoadingStatus.FAILED) {
return null;
}
return (
@ -40,7 +40,7 @@ export function ContextErrorMessage({ status, reason }: ContextErrorMessageProps
data-test-subj="contextErrorMessageTitle"
>
<EuiText data-test-subj="contextErrorMessageBody">
{reason === FailureReason.UNKNOWN && (
{status.reason === FailureReason.UNKNOWN && (
<FormattedMessage
id="discover.context.reloadPageDescription.reloadOrVisitTextMessage"
defaultMessage="Please reload or go back to the document list to select a valid anchor document."