[Discover] Refactor discover.js controller topnav code (#79062)

* Move discover.js functions to helper functions in separate files

* Convert to TypeScript

* Add unit tests

* Add removeField function to SearchSource
This commit is contained in:
Matthias Wilhelm 2020-11-25 11:07:08 +01:00 committed by GitHub
parent 4aa1683b3b
commit f294a9e2ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1449 additions and 509 deletions

View file

@ -41,6 +41,7 @@ export declare class SearchSource
| [getSearchRequestBody()](./kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md) | | Returns body contents of the search request, often referred as query DSL. |
| [getSerializedFields()](./kibana-plugin-plugins-data-public.searchsource.getserializedfields.md) | | serializes search source fields (which can later be passed to [ISearchStartSearchSource](./kibana-plugin-plugins-data-public.isearchstartsearchsource.md)<!-- -->) |
| [onRequestStart(handler)](./kibana-plugin-plugins-data-public.searchsource.onrequeststart.md) | | Add a handler that will be notified whenever requests start |
| [removeField(field)](./kibana-plugin-plugins-data-public.searchsource.removefield.md) | | remove field |
| [serialize()](./kibana-plugin-plugins-data-public.searchsource.serialize.md) | | Serializes the instance to a JSON string and a set of referenced objects. Use this method to get a representation of the search source which can be stored in a saved object.<!-- -->The references returned by this function can be mixed with other references in the same object, however make sure there are no name-collisions. The references will be named <code>kibanaSavedObjectMeta.searchSourceJSON.index</code> and <code>kibanaSavedObjectMeta.searchSourceJSON.filter[&lt;number&gt;].meta.index</code>.<!-- -->Using <code>createSearchSource</code>, the instance can be re-created. |
| [setField(field, value)](./kibana-plugin-plugins-data-public.searchsource.setfield.md) | | sets value to a single search source field |
| [setFields(newFields)](./kibana-plugin-plugins-data-public.searchsource.setfields.md) | | Internal, do not use. Overrides all search source fields with the new field array. |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) &gt; [removeField](./kibana-plugin-plugins-data-public.searchsource.removefield.md)
## SearchSource.removeField() method
remove field
<b>Signature:</b>
```typescript
removeField<K extends keyof SearchSourceFields>(field: K): this;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| field | <code>K</code> | |
<b>Returns:</b>
`this`

View file

@ -28,6 +28,7 @@ export const searchSourceInstanceMock: MockedKeys<ISearchSource> = {
setPreferredSearchStrategyId: jest.fn(),
setFields: jest.fn().mockReturnThis(),
setField: jest.fn().mockReturnThis(),
removeField: jest.fn().mockReturnThis(),
getId: jest.fn(),
getFields: jest.fn(),
getField: jest.fn(),

View file

@ -82,6 +82,15 @@ describe('SearchSource', () => {
});
});
describe('#removeField()', () => {
test('remove property', () => {
const searchSource = new SearchSource({}, searchSourceDependencies);
searchSource.setField('aggs', 5);
searchSource.removeField('aggs');
expect(searchSource.getField('aggs')).toBeFalsy();
});
});
describe(`#setField('index')`, () => {
describe('auto-sourceFiltering', () => {
describe('new index pattern assigned', () => {

View file

@ -142,10 +142,18 @@ export class SearchSource {
*/
setField<K extends keyof SearchSourceFields>(field: K, value: SearchSourceFields[K]) {
if (value == null) {
delete this.fields[field];
} else {
this.fields[field] = value;
return this.removeField(field);
}
this.fields[field] = value;
return this;
}
/**
* remove field
* @param field: field name
*/
removeField<K extends keyof SearchSourceFields>(field: K) {
delete this.fields[field];
return this;
}

View file

@ -2169,6 +2169,7 @@ export class SearchSource {
// (undocumented)
history: SearchRequest[];
onRequestStart(handler: (searchSource: SearchSource, options?: ISearchOptions) => Promise<unknown>): void;
removeField<K extends keyof SearchSourceFields>(field: K): this;
serialize(): {
searchSourceJSON: string;
references: import("src/core/server").SavedObjectReference[];

View file

@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IUiSettingsClient } from '../../../../core/public';
export const configMock = ({
get: (key: string) => {
if (key === 'defaultIndex') {
return 'the-index-pattern-id';
}
return '';
},
} as unknown) as IUiSettingsClient;

View file

@ -0,0 +1,74 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IndexPattern, indexPatterns } from '../kibana_services';
import { IIndexPatternFieldList } from '../../../data/common/index_patterns/fields';
const fields = [
{
name: '_index',
type: 'string',
scripted: false,
filterable: true,
},
{
name: 'message',
type: 'string',
scripted: false,
filterable: false,
},
{
name: 'extension',
type: 'string',
scripted: false,
filterable: true,
},
{
name: 'bytes',
type: 'number',
scripted: false,
filterable: true,
},
{
name: 'scripted',
type: 'number',
scripted: true,
filterable: false,
},
] as IIndexPatternFieldList;
fields.getByName = (name: string) => {
return fields.find((field) => field.name === name);
};
const indexPattern = ({
id: 'the-index-pattern-id',
title: 'the-index-pattern-title',
metaFields: ['_index', '_score'],
flattenHit: undefined,
formatHit: jest.fn((hit) => hit._source),
fields,
getComputedFields: () => ({}),
getSourceFiltering: () => ({}),
getFieldByName: () => ({}),
} as unknown) as IndexPattern;
indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields);
export const indexPatternMock = indexPattern;

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IndexPatternsService } from '../../../data/common';
import { indexPatternMock } from './index_pattern';
export const indexPatternsMock = ({
getCache: () => {
return [indexPatternMock];
},
get: (id: string) => {
if (id === 'the-index-pattern-id') {
return indexPatternMock;
}
},
} as unknown) as IndexPatternsService;

View file

@ -0,0 +1,41 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedSearch } from '../saved_searches';
export const savedSearchMock = ({
id: 'the-saved-search-id',
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,
} as unknown) as SavedSearch;

View file

@ -18,8 +18,7 @@
*/
import _ from 'lodash';
import React from 'react';
import { Subscription, Subject, merge } from 'rxjs';
import { merge, Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import moment from 'moment';
import dateMath from '@elastic/datemath';
@ -28,31 +27,52 @@ import { getState, splitState } from './discover_state';
import { RequestAdapter } from '../../../../inspector/public';
import {
connectToQueryState,
esFilters,
indexPatterns as indexPatternsUtils,
connectToQueryState,
syncQueryStateWithUrl,
} from '../../../../data/public';
import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public';
import { getSortArray, getSortForSearchSource } from './doc_table';
import { getSortArray } from './doc_table';
import { createFixedScroll } from './directives/fixed_scroll';
import * as columnActions from './doc_table/actions/columns';
import indexTemplateLegacy from './discover_legacy.html';
import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel';
import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util';
import { discoverResponseHandler } from './response_handler';
import {
getAngularModule,
getHeaderActionMenuMounter,
getRequestInspectorStats,
getResponseInspectorStats,
getServices,
getHeaderActionMenuMounter,
getUrlTracker,
unhashUrl,
redirectWhenMissing,
subscribeWithScope,
tabifyAggResponse,
getAngularModule,
redirectWhenMissing,
} from '../../kibana_services';
import {
getRootBreadcrumbs,
getSavedSearchBreadcrumbs,
setBreadcrumbsTitle,
} from '../helpers/breadcrumbs';
import { validateTimeRange } from '../helpers/validate_time_range';
import { popularizeField } from '../helpers/popularize_field';
import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state';
import { addFatalError } from '../../../../kibana_legacy/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public';
import {
DEFAULT_COLUMNS_SETTING,
MODIFY_COLUMNS_ON_SWITCH,
SAMPLE_SIZE_SETTING,
SEARCH_ON_PAGE_LOAD_SETTING,
} from '../../../common';
import { resolveIndexPattern, loadIndexPattern } from '../helpers/resolve_index_pattern';
import { getTopNavLinks } from '../components/top_nav/get_top_nav_links';
import { updateSearchSource } from '../helpers/update_search_source';
import { calcFieldCounts } from '../helpers/calc_field_counts';
const services = getServices();
const {
core,
@ -61,30 +81,11 @@ const {
history: getHistory,
indexPatterns,
filterManager,
share,
timefilter,
toastNotifications,
uiSettings: config,
trackUiMetric,
} = getServices();
import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs';
import { validateTimeRange } from '../helpers/validate_time_range';
import { popularizeField } from '../helpers/popularize_field';
import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state';
import { getIndexPatternId } from '../helpers/get_index_pattern_id';
import { addFatalError } from '../../../../kibana_legacy/public';
import {
DEFAULT_COLUMNS_SETTING,
SAMPLE_SIZE_SETTING,
SORT_DEFAULT_ORDER_SETTING,
SEARCH_ON_PAGE_LOAD_SETTING,
DOC_HIDE_TIME_COLUMN_SETTING,
MODIFY_COLUMNS_ON_SWITCH,
} from '../../../common';
import { METRIC_TYPE } from '@kbn/analytics';
import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public';
} = services;
const fetchStatuses = {
UNINITIALIZED: 'uninitialized',
@ -132,24 +133,7 @@ app.config(($routeProvider) => {
const { appStateContainer } = getState({ history });
const { index } = appStateContainer.getState();
return Promise.props({
ip: indexPatterns.getCache().then((indexPatternList) => {
/**
* In making the indexPattern modifiable it was placed in appState. Unfortunately,
* the load order of AppState conflicts with the load order of many other things
* so in order to get the name of the index we should use, and to switch to the
* default if necessary, we parse the appState with a temporary State object and
* then destroy it immediatly after we're done
*
* @type {State}
*/
const id = getIndexPatternId(index, indexPatternList, config.get('defaultIndex'));
return Promise.props({
list: indexPatternList,
loaded: indexPatterns.get(id),
stateVal: index,
stateValFound: !!index && id === index,
});
}),
ip: loadIndexPattern(index, data.indexPatterns, config),
savedSearch: getServices()
.getSavedSearchById(savedSearchId)
.then((savedSearch) => {
@ -204,7 +188,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
let inspectorRequest;
const savedSearch = $route.current.locals.savedObjects.savedSearch;
$scope.searchSource = savedSearch.searchSource;
$scope.indexPattern = resolveIndexPatternLoading();
$scope.indexPattern = resolveIndexPattern(
$route.current.locals.savedObjects.ip,
$scope.searchSource,
toastNotifications
);
//used for functional testing
$scope.fetchCounter = 0;
@ -216,22 +204,22 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
// used for restoring background session
let isInitialSearch = true;
const state = getState({
getStateDefaults,
storeInSessionStorage: config.get('state:storeInSessionStorage'),
history,
toasts: core.notifications.toasts,
});
const {
appStateContainer,
startSync: startStateSync,
stopSync: stopStateSync,
setAppState,
replaceUrlAppState,
isAppStateDirty,
kbnUrlStateStorage,
getPreviousAppState,
resetInitialAppState,
} = getState({
defaultAppState: getStateDefaults(),
storeInSessionStorage: config.get('state:storeInSessionStorage'),
history,
toasts: core.notifications.toasts,
});
} = state;
if (appStateContainer.getState().index !== $scope.indexPattern.id) {
//used index pattern is different than the given by url/state which is invalid
setAppState({ index: $scope.indexPattern.id });
@ -349,145 +337,36 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
unlistenHistoryBasePath();
});
const getTopNavLinks = () => {
const newSearch = {
id: 'new',
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
defaultMessage: 'New',
}),
description: i18n.translate('discover.localMenu.newSearchDescription', {
defaultMessage: 'New Search',
}),
run: function () {
$scope.$evalAsync(() => {
history.push('/');
});
},
testId: 'discoverNewButton',
};
const getFieldCounts = async () => {
// the field counts aren't set until we have the data back,
// so we wait for the fetch to be done before proceeding
if ($scope.fetchStatus === fetchStatuses.COMPLETE) {
return $scope.fieldCounts;
}
const saveSearch = {
id: 'save',
label: i18n.translate('discover.localMenu.saveTitle', {
defaultMessage: 'Save',
}),
description: i18n.translate('discover.localMenu.saveSearchDescription', {
defaultMessage: 'Save Search',
}),
testId: 'discoverSaveButton',
run: async () => {
const onSave = ({
newTitle,
newCopyOnSave,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}) => {
const currentTitle = savedSearch.title;
savedSearch.title = newTitle;
savedSearch.copyOnSave = newCopyOnSave;
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
return saveDataSource(saveOptions).then((response) => {
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedSearch.title = currentTitle;
} else {
resetInitialAppState();
}
return response;
});
};
const saveModal = (
<SavedObjectSaveModal
onSave={onSave}
onClose={() => {}}
title={savedSearch.title}
showCopyOnSave={!!savedSearch.id}
objectType="search"
description={i18n.translate('discover.localMenu.saveSaveSearchDescription', {
defaultMessage:
'Save your Discover search so you can use it in visualizations and dashboards',
})}
showDescription={false}
/>
);
showSaveModal(saveModal, core.i18n.Context);
},
};
const openSearch = {
id: 'open',
label: i18n.translate('discover.localMenu.openTitle', {
defaultMessage: 'Open',
}),
description: i18n.translate('discover.localMenu.openSavedSearchDescription', {
defaultMessage: 'Open Saved Search',
}),
testId: 'discoverOpenButton',
run: () => {
showOpenSearchPanel({
makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`,
I18nContext: core.i18n.Context,
});
},
};
const shareSearch = {
id: 'share',
label: i18n.translate('discover.localMenu.shareTitle', {
defaultMessage: 'Share',
}),
description: i18n.translate('discover.localMenu.shareSearchDescription', {
defaultMessage: 'Share Search',
}),
testId: 'shareTopNavButton',
run: async (anchorElement) => {
const sharingData = await this.getSharingData();
share.toggleShareContextMenu({
anchorElement,
allowEmbed: false,
allowShortUrl: uiCapabilities.discover.createShortUrl,
shareableUrl: unhashUrl(window.location.href),
objectId: savedSearch.id,
objectType: 'search',
sharingData: {
...sharingData,
title: savedSearch.title,
},
isDirty: !savedSearch.id || isAppStateDirty(),
});
},
};
const inspectSearch = {
id: 'inspect',
label: i18n.translate('discover.localMenu.inspectTitle', {
defaultMessage: 'Inspect',
}),
description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', {
defaultMessage: 'Open Inspector for search',
}),
testId: 'openInspectorButton',
run() {
getServices().inspector.open(inspectorAdapters, {
title: savedSearch.title,
});
},
};
return [
newSearch,
...(uiCapabilities.discover.save ? [saveSearch] : []),
openSearch,
shareSearch,
inspectSearch,
];
return await new Promise((resolve) => {
const unwatch = $scope.$watch('fetchStatus', (newValue) => {
if (newValue === fetchStatuses.COMPLETE) {
unwatch();
resolve($scope.fieldCounts);
}
});
});
};
$scope.topNavMenu = getTopNavLinks();
$scope.topNavMenu = getTopNavLinks({
getFieldCounts,
indexPattern: $scope.indexPattern,
inspectorAdapters,
navigateTo: (path) => {
$scope.$evalAsync(() => {
history.push(path);
});
},
savedSearch,
services,
state,
});
$scope.searchSource
.setField('index', $scope.indexPattern)
@ -511,96 +390,8 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
chrome.docTitle.change(`Discover${pageTitleSuffix}`);
const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', {
defaultMessage: 'Discover',
});
if (savedSearch.id && savedSearch.title) {
chrome.setBreadcrumbs([
{
text: discoverBreadcrumbsTitle,
href: '#/',
},
{ text: savedSearch.title },
]);
} else {
chrome.setBreadcrumbs([
{
text: discoverBreadcrumbsTitle,
},
]);
}
const getFieldCounts = async () => {
// the field counts aren't set until we have the data back,
// so we wait for the fetch to be done before proceeding
if ($scope.fetchStatus === fetchStatuses.COMPLETE) {
return $scope.fieldCounts;
}
return await new Promise((resolve) => {
const unwatch = $scope.$watch('fetchStatus', (newValue) => {
if (newValue === fetchStatuses.COMPLETE) {
unwatch();
resolve($scope.fieldCounts);
}
});
});
};
const getSharingDataFields = async (selectedFields, timeFieldName, hideTimeColumn) => {
if (selectedFields.length === 1 && selectedFields[0] === '_source') {
const fieldCounts = await getFieldCounts();
return {
searchFields: null,
selectFields: _.keys(fieldCounts).sort(),
};
}
const fields =
timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields;
return {
searchFields: fields,
selectFields: fields,
};
};
this.getSharingData = async () => {
const searchSource = $scope.searchSource.createCopy();
const { searchFields, selectFields } = await getSharingDataFields(
$scope.state.columns,
$scope.indexPattern.timeFieldName,
config.get(DOC_HIDE_TIME_COLUMN_SETTING)
);
searchSource.setField('fields', searchFields);
searchSource.setField(
'sort',
getSortForSearchSource(
$scope.state.sort,
$scope.indexPattern,
config.get(SORT_DEFAULT_ORDER_SETTING)
)
);
searchSource.setField('highlight', null);
searchSource.setField('highlightAll', null);
searchSource.setField('aggs', null);
searchSource.setField('size', null);
const body = await searchSource.getSearchRequestBody();
return {
searchRequest: {
index: searchSource.getField('index').title,
body,
},
fields: selectFields,
metaFields: $scope.indexPattern.metaFields,
conflictedTypesFields: $scope.indexPattern.fields
.filter((f) => f.type === 'conflict')
.map((f) => f.name),
indexPatternId: searchSource.getField('index').id,
};
};
setBreadcrumbsTitle(savedSearch, chrome);
function getStateDefaults() {
const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery();
@ -739,57 +530,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
});
});
async function saveDataSource(saveOptions) {
await $scope.updateDataSource();
savedSearch.columns = $scope.state.columns;
savedSearch.sort = $scope.state.sort;
try {
const id = await savedSearch.save(saveOptions);
$scope.$evalAsync(() => {
if (id) {
toastNotifications.addSuccess({
title: i18n.translate('discover.notifications.savedSearchTitle', {
defaultMessage: `Search '{savedSearchTitle}' was saved`,
values: {
savedSearchTitle: savedSearch.title,
},
}),
'data-test-subj': 'saveSearchSuccess',
});
if (savedSearch.id !== $route.current.params.id) {
history.push(`/view/${encodeURIComponent(savedSearch.id)}`);
} else {
// Update defaults so that "reload saved query" functions correctly
setAppState(getStateDefaults());
chrome.docTitle.change(savedSearch.lastSavedTitle);
chrome.setBreadcrumbs([
{
text: discoverBreadcrumbsTitle,
href: '#/',
},
{ text: savedSearch.title },
]);
}
}
});
return { id };
} catch (saveError) {
toastNotifications.addDanger({
title: i18n.translate('discover.notifications.notSavedSearchTitle', {
defaultMessage: `Search '{savedSearchTitle}' was not saved.`,
values: {
savedSearchTitle: savedSearch.title,
},
}),
text: saveError.message,
});
return { error: saveError };
}
}
$scope.opts.fetch = $scope.fetch = function () {
// ignore requests to fetch before the app inits
if (!init.complete) return;
@ -907,16 +647,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
$scope.hits = resp.hits.total;
$scope.rows = resp.hits.hits;
// if we haven't counted yet, reset the counts
const counts = ($scope.fieldCounts = $scope.fieldCounts || {});
$scope.rows.forEach((hit) => {
const fields = Object.keys($scope.indexPattern.flattenHit(hit));
fields.forEach((fieldName) => {
counts[fieldName] = (counts[fieldName] || 0) + 1;
});
});
$scope.fieldCounts = calcFieldCounts(
$scope.fieldCounts || {},
resp.hits.hits,
$scope.indexPattern
);
$scope.fetchStatus = fetchStatuses.COMPLETE;
}
@ -944,13 +679,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
};
};
$scope.toMoment = function (datetime) {
if (!datetime) {
return;
}
return moment(datetime).format(config.get('dateFormat'));
};
$scope.resetQuery = function () {
history.push(
$route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/'
@ -979,20 +707,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
};
$scope.updateDataSource = () => {
const { indexPattern, searchSource } = $scope;
searchSource
.setField('index', $scope.indexPattern)
.setField('size', $scope.opts.sampleSize)
.setField(
'sort',
getSortForSearchSource(
$scope.state.sort,
indexPattern,
config.get(SORT_DEFAULT_ORDER_SETTING)
)
)
.setField('query', data.query.queryString.getQuery() || null)
.setField('filter', filterManager.getFilters());
updateSearchSource($scope.searchSource, {
indexPattern: $scope.indexPattern,
services,
sort: $scope.state.sort,
});
return Promise.resolve();
};
@ -1044,11 +763,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
const columns = columnActions.moveColumn($scope.state.columns, columnName, newIndex);
setAppState({ columns });
};
$scope.scrollToTop = function () {
$window.scrollTo(0, 0);
};
async function setupVisualization() {
// If no timefield has been specified we don't create a histogram of messages
if (!getTimeField()) return;
@ -1085,62 +799,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
});
}
function getIndexPatternWarning(index) {
return i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', {
defaultMessage: '{stateVal} is not a configured index pattern ID',
values: {
stateVal: `"${index}"`,
},
});
}
function resolveIndexPatternLoading() {
const {
loaded: loadedIndexPattern,
stateVal,
stateValFound,
} = $route.current.locals.savedObjects.ip;
const ownIndexPattern = $scope.searchSource.getOwnField('index');
if (ownIndexPattern && !stateVal) {
return ownIndexPattern;
}
if (stateVal && !stateValFound) {
const warningTitle = getIndexPatternWarning();
if (ownIndexPattern) {
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', {
defaultMessage:
'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})',
values: {
ownIndexPatternTitle: ownIndexPattern.title,
ownIndexPatternId: ownIndexPattern.id,
},
}),
});
return ownIndexPattern;
}
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', {
defaultMessage:
'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})',
values: {
loadedIndexPatternTitle: loadedIndexPattern.title,
loadedIndexPatternId: loadedIndexPattern.id,
},
}),
});
}
return loadedIndexPattern;
}
addHelpMenuToAppChrome(chrome);
init();

View file

@ -29,7 +29,7 @@ describe('Test discover state', () => {
history = createBrowserHistory();
history.push('/');
state = getState({
defaultAppState: { index: 'test' },
getStateDefaults: () => ({ index: 'test' }),
history,
});
await state.replaceUrlAppState({});
@ -84,7 +84,7 @@ describe('Test discover state with legacy migration', () => {
"/#?_a=(query:(query_string:(analyze_wildcard:!t,query:'type:nice%20name:%22yeah%22')))"
);
state = getState({
defaultAppState: { index: 'test' },
getStateDefaults: () => ({ index: 'test' }),
history,
});
expect(state.appStateContainer.getState()).toMatchInlineSnapshot(`

View file

@ -65,7 +65,7 @@ interface GetStateParams {
/**
* Default state used for merging with with URL state to get the initial state
*/
defaultAppState?: AppState;
getStateDefaults?: () => AppState;
/**
* Determins the use of long vs. short/hashed urls
*/
@ -123,7 +123,11 @@ export interface GetStateReturn {
/**
* Returns whether the current app state is different to the initial state
*/
isAppStateDirty: () => void;
isAppStateDirty: () => boolean;
/**
* Reset AppState to default, discarding all changes
*/
resetAppState: () => void;
}
const APP_STATE_URL_KEY = '_a';
@ -132,11 +136,12 @@ const APP_STATE_URL_KEY = '_a';
* Used to sync URL with UI state
*/
export function getState({
defaultAppState = {},
getStateDefaults,
storeInSessionStorage = false,
history,
toasts,
}: GetStateParams): GetStateReturn {
const defaultAppState = getStateDefaults ? getStateDefaults() : {};
const stateStorage = createKbnUrlStateStorage({
useHash: storeInSessionStorage,
history,
@ -185,6 +190,10 @@ export function getState({
resetInitialAppState: () => {
initialAppState = appStateContainer.getState();
},
resetAppState: () => {
const defaultState = getStateDefaults ? getStateDefaults() : {};
setState(appStateContainerModified, defaultState);
},
getPreviousAppState: () => previousAppState,
flushToUrl: () => stateStorage.flush(),
isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()),

View file

@ -3,7 +3,7 @@
exports[`render 1`] = `
<EuiFlyout
data-test-subj="loadSearchForm"
onClose={[Function]}
onClose={[MockFunction]}
ownFocus={true}
>
<EuiFlyoutHeader
@ -54,7 +54,7 @@ exports[`render 1`] = `
<EuiButton
fill={true}
href="/app/management/kibana/objects?_a=(tab:search)"
onClick={[Function]}
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Manage searches"

View file

@ -0,0 +1,85 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getTopNavLinks } from './get_top_nav_links';
import { inspectorPluginMock } from '../../../../../inspector/public/mocks';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { savedSearchMock } from '../../../__mocks__/saved_search';
import { DiscoverServices } from '../../../build_services';
import { GetStateReturn } from '../../angular/discover_state';
const services = ({
capabilities: {
discover: {
save: true,
},
},
} as unknown) as DiscoverServices;
const state = ({} as unknown) as GetStateReturn;
test('getTopNavLinks result', () => {
const topNavLinks = getTopNavLinks({
getFieldCounts: jest.fn(),
indexPattern: indexPatternMock,
inspectorAdapters: inspectorPluginMock,
navigateTo: jest.fn(),
savedSearch: savedSearchMock,
services,
state,
});
expect(topNavLinks).toMatchInlineSnapshot(`
Array [
Object {
"description": "New Search",
"id": "new",
"label": "New",
"run": [Function],
"testId": "discoverNewButton",
},
Object {
"description": "Save Search",
"id": "save",
"label": "Save",
"run": [Function],
"testId": "discoverSaveButton",
},
Object {
"description": "Open Saved Search",
"id": "open",
"label": "Open",
"run": [Function],
"testId": "discoverOpenButton",
},
Object {
"description": "Share Search",
"id": "share",
"label": "Share",
"run": [Function],
"testId": "shareTopNavButton",
},
Object {
"description": "Open Inspector for search",
"id": "inspect",
"label": "Inspect",
"run": [Function],
"testId": "openInspectorButton",
},
]
`);
});

View file

@ -0,0 +1,148 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { showOpenSearchPanel } from './show_open_search_panel';
import { getSharingData } from '../../helpers/get_sharing_data';
import { unhashUrl } from '../../../../../kibana_utils/public';
import { DiscoverServices } from '../../../build_services';
import { Adapters } from '../../../../../inspector/common/adapters';
import { SavedSearch } from '../../../saved_searches';
import { onSaveSearch } from './on_save_search';
import { GetStateReturn } from '../../angular/discover_state';
import { IndexPattern } from '../../../kibana_services';
/**
* Helper function to build the top nav links
*/
export const getTopNavLinks = ({
getFieldCounts,
indexPattern,
inspectorAdapters,
navigateTo,
savedSearch,
services,
state,
}: {
getFieldCounts: () => Promise<Record<string, number>>;
indexPattern: IndexPattern;
inspectorAdapters: Adapters;
navigateTo: (url: string) => void;
savedSearch: SavedSearch;
services: DiscoverServices;
state: GetStateReturn;
}) => {
const newSearch = {
id: 'new',
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
defaultMessage: 'New',
}),
description: i18n.translate('discover.localMenu.newSearchDescription', {
defaultMessage: 'New Search',
}),
run: () => navigateTo('/'),
testId: 'discoverNewButton',
};
const saveSearch = {
id: 'save',
label: i18n.translate('discover.localMenu.saveTitle', {
defaultMessage: 'Save',
}),
description: i18n.translate('discover.localMenu.saveSearchDescription', {
defaultMessage: 'Save Search',
}),
testId: 'discoverSaveButton',
run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }),
};
const openSearch = {
id: 'open',
label: i18n.translate('discover.localMenu.openTitle', {
defaultMessage: 'Open',
}),
description: i18n.translate('discover.localMenu.openSavedSearchDescription', {
defaultMessage: 'Open Saved Search',
}),
testId: 'discoverOpenButton',
run: () =>
showOpenSearchPanel({
makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`,
I18nContext: services.core.i18n.Context,
}),
};
const shareSearch = {
id: 'share',
label: i18n.translate('discover.localMenu.shareTitle', {
defaultMessage: 'Share',
}),
description: i18n.translate('discover.localMenu.shareSearchDescription', {
defaultMessage: 'Share Search',
}),
testId: 'shareTopNavButton',
run: async (anchorElement: HTMLElement) => {
if (!services.share) {
return;
}
const sharingData = await getSharingData(
savedSearch.searchSource,
state.appStateContainer.getState(),
services.uiSettings,
getFieldCounts
);
services.share.toggleShareContextMenu({
anchorElement,
allowEmbed: false,
allowShortUrl: !!services.capabilities.discover.createShortUrl,
shareableUrl: unhashUrl(window.location.href),
objectId: savedSearch.id,
objectType: 'search',
sharingData: {
...sharingData,
title: savedSearch.title,
},
isDirty: !savedSearch.id || state.isAppStateDirty(),
});
},
};
const inspectSearch = {
id: 'inspect',
label: i18n.translate('discover.localMenu.inspectTitle', {
defaultMessage: 'Inspect',
}),
description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', {
defaultMessage: 'Open Inspector for search',
}),
testId: 'openInspectorButton',
run: () => {
services.inspector.open(inspectorAdapters, {
title: savedSearch.title,
});
},
};
return [
newSearch,
...(services.capabilities.discover.save ? [saveSearch] : []),
openSearch,
shareSearch,
inspectSearch,
];
};

View file

@ -0,0 +1,47 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { showSaveModal } from '../../../../../saved_objects/public';
jest.mock('../../../../../saved_objects/public');
import { onSaveSearch } from './on_save_search';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { savedSearchMock } from '../../../__mocks__/saved_search';
import { DiscoverServices } from '../../../build_services';
import { GetStateReturn } from '../../angular/discover_state';
import { i18nServiceMock } from '../../../../../../core/public/mocks';
test('onSaveSearch', async () => {
const serviceMock = ({
core: {
i18n: i18nServiceMock.create(),
},
} as unknown) as DiscoverServices;
const stateMock = ({} as unknown) as GetStateReturn;
await onSaveSearch({
indexPattern: indexPatternMock,
navigateTo: jest.fn(),
savedSearch: savedSearchMock,
services: serviceMock,
state: stateMock,
});
expect(showSaveModal).toHaveBeenCalled();
});

View file

@ -0,0 +1,158 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SavedObjectSaveModal, showSaveModal } from '../../../../../saved_objects/public';
import { SavedSearch } from '../../../saved_searches';
import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns';
import { DiscoverServices } from '../../../build_services';
import { GetStateReturn } from '../../angular/discover_state';
import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs';
import { persistSavedSearch } from '../../helpers/persist_saved_search';
async function saveDataSource({
indexPattern,
navigateTo,
savedSearch,
saveOptions,
services,
state,
}: {
indexPattern: IndexPattern;
navigateTo: (url: string) => void;
savedSearch: SavedSearch;
saveOptions: {
confirmOverwrite: boolean;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
};
services: DiscoverServices;
state: GetStateReturn;
}) {
const prevSavedSearchId = savedSearch.id;
function onSuccess(id: string) {
if (id) {
services.toastNotifications.addSuccess({
title: i18n.translate('discover.notifications.savedSearchTitle', {
defaultMessage: `Search '{savedSearchTitle}' was saved`,
values: {
savedSearchTitle: savedSearch.title,
},
}),
'data-test-subj': 'saveSearchSuccess',
});
if (savedSearch.id !== prevSavedSearchId) {
navigateTo(`/view/${encodeURIComponent(savedSearch.id)}`);
} else {
// Update defaults so that "reload saved query" functions correctly
state.resetAppState();
services.chrome.docTitle.change(savedSearch.lastSavedTitle!);
setBreadcrumbsTitle(savedSearch, services.chrome);
}
}
}
function onError(error: Error) {
services.toastNotifications.addDanger({
title: i18n.translate('discover.notifications.notSavedSearchTitle', {
defaultMessage: `Search '{savedSearchTitle}' was not saved.`,
values: {
savedSearchTitle: savedSearch.title,
},
}),
text: error.message,
});
}
return persistSavedSearch(savedSearch, {
indexPattern,
onError,
onSuccess,
saveOptions,
services,
state: state.appStateContainer.getState(),
});
}
export async function onSaveSearch({
indexPattern,
navigateTo,
savedSearch,
services,
state,
}: {
indexPattern: IndexPattern;
navigateTo: (path: string) => void;
savedSearch: SavedSearch;
services: DiscoverServices;
state: GetStateReturn;
}) {
const onSave = async ({
newTitle,
newCopyOnSave,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}: {
newTitle: string;
newCopyOnSave: boolean;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
}) => {
const currentTitle = savedSearch.title;
savedSearch.title = newTitle;
savedSearch.copyOnSave = newCopyOnSave;
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
const response = await saveDataSource({
indexPattern,
saveOptions,
services,
navigateTo,
savedSearch,
state,
});
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedSearch.title = currentTitle;
} else {
state.resetInitialAppState();
}
return response;
};
const saveModal = (
<SavedObjectSaveModal
onSave={onSave}
onClose={() => {}}
title={savedSearch.title}
showCopyOnSave={!!savedSearch.id}
objectType="search"
description={i18n.translate('discover.localMenu.saveSaveSearchDescription', {
defaultMessage:
'Save your Discover search so you can use it in visualizations and dashboards',
})}
showDescription={false}
/>
);
showSaveModal(saveModal, services.core.i18n.Context);
}

View file

@ -24,7 +24,7 @@ jest.mock('../../../kibana_services', () => {
return {
getServices: () => ({
core: { uiSettings: {}, savedObjects: {} },
addBasePath: (path) => path,
addBasePath: (path: string) => path,
}),
};
});
@ -32,6 +32,6 @@ jest.mock('../../../kibana_services', () => {
import { OpenSearchPanel } from './open_search_panel';
test('render', () => {
const component = shallow(<OpenSearchPanel onClose={() => {}} makeUrl={() => {}} />);
const component = shallow(<OpenSearchPanel onClose={jest.fn()} makeUrl={jest.fn()} />);
expect(component).toMatchSnapshot();
});

View file

@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import rison from 'rison-node';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -37,7 +35,12 @@ import { getServices } from '../../../kibana_services';
const SEARCH_OBJECT_TYPE = 'search';
export function OpenSearchPanel(props) {
interface OpenSearchPanelProps {
onClose: () => void;
makeUrl: (id: string) => string;
}
export function OpenSearchPanel(props: OpenSearchPanelProps) {
const {
core: { uiSettings, savedObjects },
addBasePath,
@ -102,8 +105,3 @@ export function OpenSearchPanel(props) {
</EuiFlyout>
);
}
OpenSearchPanel.propTypes = {
onClose: PropTypes.func.isRequired,
makeUrl: PropTypes.func.isRequired,
};

View file

@ -19,11 +19,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nStart } from 'kibana/public';
import { OpenSearchPanel } from './open_search_panel';
let isOpen = false;
export function showOpenSearchPanel({ makeUrl, I18nContext }) {
export function showOpenSearchPanel({
makeUrl,
I18nContext,
}: {
makeUrl: (path: string) => string;
I18nContext: I18nStart['Context'];
}) {
if (isOpen) {
return;
}

View file

@ -17,7 +17,9 @@
* under the License.
*/
import { ChromeStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { SavedSearch } from '../../saved_searches';
export function getRootBreadcrumbs() {
return [
@ -38,3 +40,29 @@ export function getSavedSearchBreadcrumbs($route: any) {
},
];
}
/**
* Helper function to set the Discover's breadcrumb
* if there's an active savedSearch, its title is appended
*/
export function setBreadcrumbsTitle(savedSearch: SavedSearch, chrome: ChromeStart) {
const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', {
defaultMessage: 'Discover',
});
if (savedSearch.id && savedSearch.title) {
chrome.setBreadcrumbs([
{
text: discoverBreadcrumbsTitle,
href: '#/',
},
{ text: savedSearch.title },
]);
} else {
chrome.setBreadcrumbs([
{
text: discoverBreadcrumbsTitle,
},
]);
}
}

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { calcFieldCounts } from './calc_field_counts';
import { indexPatternMock } from '../../__mocks__/index_pattern';
describe('calcFieldCounts', () => {
test('returns valid field count data', async () => {
const rows = [
{ _id: 1, _source: { message: 'test1', bytes: 20 } },
{ _id: 2, _source: { name: 'test2', extension: 'jpg' } },
];
const result = calcFieldCounts({}, rows, indexPatternMock);
expect(result).toMatchInlineSnapshot(`
Object {
"_index": 2,
"_score": 2,
"bytes": 1,
"extension": 1,
"message": 1,
"name": 1,
}
`);
});
test('updates field count data', async () => {
const rows = [
{ _id: 1, _source: { message: 'test1', bytes: 20 } },
{ _id: 2, _source: { name: 'test2', extension: 'jpg' } },
];
const result = calcFieldCounts({ message: 2 }, rows, indexPatternMock);
expect(result).toMatchInlineSnapshot(`
Object {
"_index": 2,
"_score": 2,
"bytes": 1,
"extension": 1,
"message": 3,
"name": 1,
}
`);
});
});

View file

@ -0,0 +1,38 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IndexPattern } from '../../kibana_services';
/**
* This function is recording stats of the available fields, for usage in sidebar and sharing
* Note that this values aren't displayed, but used for internal calculations
*/
export function calcFieldCounts(
counts = {} as Record<string, number>,
rows: Array<Record<string, any>>,
indexPattern: IndexPattern
) {
for (const hit of rows) {
const fields = Object.keys(indexPattern.flattenHit(hit));
for (const fieldName of fields) {
counts[fieldName] = (counts[fieldName] || 0) + 1;
}
}
return counts;
}

View file

@ -1,60 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IIndexPattern } from '../../../../data/common/index_patterns';
export function findIndexPatternById(
indexPatterns: IIndexPattern[],
id: string
): IIndexPattern | undefined {
if (!Array.isArray(indexPatterns) || !id) {
return;
}
return indexPatterns.find((o) => o.id === id);
}
/**
* Checks if the given defaultIndex exists and returns
* the first available index pattern id if not
*/
export function getFallbackIndexPatternId(
indexPatterns: IIndexPattern[],
defaultIndex: string = ''
): string {
if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) {
return defaultIndex;
}
return !indexPatterns || !indexPatterns.length || !indexPatterns[0].id ? '' : indexPatterns[0].id;
}
/**
* A given index pattern id is checked for existence and a fallback is provided if it doesn't exist
* The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid
* the first entry of the given list of Indexpatterns is used
*/
export function getIndexPatternId(
id: string = '',
indexPatterns: IIndexPattern[],
defaultIndex: string = ''
): string {
if (!id || !findIndexPatternById(indexPatterns, id)) {
return getFallbackIndexPatternId(indexPatterns, defaultIndex);
}
return id;
}

View file

@ -0,0 +1,70 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getSharingData } from './get_sharing_data';
import { IUiSettingsClient } from 'kibana/public';
import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks';
import { indexPatternMock } from '../../__mocks__/index_pattern';
describe('getSharingData', () => {
test('returns valid data for sharing', async () => {
const searchSourceMock = createSearchSourceMock({ index: indexPatternMock });
const result = await getSharingData(
searchSourceMock,
{ columns: [] },
({
get: () => {
return false;
},
} as unknown) as IUiSettingsClient,
() => Promise.resolve({})
);
expect(result).toMatchInlineSnapshot(`
Object {
"conflictedTypesFields": Array [],
"fields": Array [],
"indexPatternId": "the-index-pattern-id",
"metaFields": Array [
"_index",
"_score",
],
"searchRequest": Object {
"body": Object {
"_source": Object {
"includes": Array [],
},
"docvalue_fields": Array [],
"query": Object {
"bool": Object {
"filter": Array [],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
"script_fields": Object {},
"sort": Array [],
"stored_fields": Array [],
},
"index": "the-index-pattern-title",
},
}
`);
});
});

View file

@ -0,0 +1,88 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IUiSettingsClient } from 'kibana/public';
import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common';
import { getSortForSearchSource } from '../angular/doc_table';
import { SearchSource } from '../../../../data/common';
import { AppState } from '../angular/discover_state';
import { SortOrder } from '../../saved_searches/types';
const getSharingDataFields = async (
getFieldCounts: () => Promise<Record<string, number>>,
selectedFields: string[],
timeFieldName: string,
hideTimeColumn: boolean
) => {
if (selectedFields.length === 1 && selectedFields[0] === '_source') {
const fieldCounts = await getFieldCounts();
return {
searchFields: undefined,
selectFields: Object.keys(fieldCounts).sort(),
};
}
const fields =
timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields;
return {
searchFields: fields,
selectFields: fields,
};
};
/**
* Preparing data to share the current state as link or CSV/Report
*/
export async function getSharingData(
currentSearchSource: SearchSource,
state: AppState,
config: IUiSettingsClient,
getFieldCounts: () => Promise<Record<string, number>>
) {
const searchSource = currentSearchSource.createCopy();
const index = searchSource.getField('index')!;
const { searchFields, selectFields } = await getSharingDataFields(
getFieldCounts,
state.columns || [],
index.timeFieldName || '',
config.get(DOC_HIDE_TIME_COLUMN_SETTING)
);
searchSource.setField('fields', searchFields);
searchSource.setField(
'sort',
getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING))
);
searchSource.removeField('highlight');
searchSource.removeField('highlightAll');
searchSource.removeField('aggs');
searchSource.removeField('size');
const body = await searchSource.getSearchRequestBody();
return {
searchRequest: {
index: index.title,
body,
},
fields: selectFields,
metaFields: index.metaFields,
conflictedTypesFields: index.fields.filter((f) => f.type === 'conflict').map((f) => f.name),
indexPatternId: index.id,
};
}

View file

@ -0,0 +1,65 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { updateSearchSource } from './update_search_source';
import { IndexPattern } from '../../../../data/public';
import { SavedSearch } from '../../saved_searches';
import { AppState } from '../angular/discover_state';
import { SortOrder } from '../../saved_searches/types';
import { SavedObjectSaveOpts } from '../../../../saved_objects/public';
import { DiscoverServices } from '../../build_services';
/**
* Helper function to update and persist the given savedSearch
*/
export async function persistSavedSearch(
savedSearch: SavedSearch,
{
indexPattern,
onError,
onSuccess,
services,
saveOptions,
state,
}: {
indexPattern: IndexPattern;
onError: (error: Error, savedSearch: SavedSearch) => void;
onSuccess: (id: string) => void;
saveOptions: SavedObjectSaveOpts;
services: DiscoverServices;
state: AppState;
}
) {
updateSearchSource(savedSearch.searchSource, {
indexPattern,
services,
sort: state.sort as SortOrder[],
});
savedSearch.columns = state.columns || [];
savedSearch.sort = (state.sort as SortOrder[]) || [];
try {
const id = await savedSearch.save(saveOptions);
onSuccess(id);
return { id };
} catch (saveError) {
onError(saveError, savedSearch);
return { error: saveError };
}
}

View file

@ -0,0 +1,56 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
loadIndexPattern,
getFallbackIndexPatternId,
IndexPatternSavedObject,
} from './resolve_index_pattern';
import { indexPatternsMock } from '../../__mocks__/index_patterns';
import { indexPatternMock } from '../../__mocks__/index_pattern';
import { configMock } from '../../__mocks__/config';
describe('Resolve index pattern tests', () => {
test('returns valid data for an existing index pattern', async () => {
const indexPatternId = 'the-index-pattern-id';
const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock);
expect(result.loaded).toEqual(indexPatternMock);
expect(result.stateValFound).toEqual(true);
expect(result.stateVal).toEqual(indexPatternId);
});
test('returns fallback data for an invalid index pattern', async () => {
const indexPatternId = 'invalid-id';
const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock);
expect(result.loaded).toEqual(indexPatternMock);
expect(result.stateValFound).toBe(false);
expect(result.stateVal).toBe(indexPatternId);
});
test('getFallbackIndexPatternId with an empty indexPatterns array', async () => {
const result = await getFallbackIndexPatternId([], '');
expect(result).toBe('');
});
test('getFallbackIndexPatternId with an indexPatterns array', async () => {
const list = await indexPatternsMock.getCache();
const result = await getFallbackIndexPatternId(
(list as unknown) as IndexPatternSavedObject[],
''
);
expect(result).toBe('the-index-pattern-id');
});
});

View file

@ -0,0 +1,158 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { IUiSettingsClient, SavedObject, ToastsStart } from 'kibana/public';
import { IndexPattern } from '../../kibana_services';
import { IndexPatternsService, SearchSource } from '../../../../data/common';
export type IndexPatternSavedObject = SavedObject & { title: string };
interface IndexPatternData {
/**
* List of existing index patterns
*/
list: IndexPatternSavedObject[];
/**
* Loaded index pattern (might be default index pattern if requested was not found)
*/
loaded: IndexPattern;
/**
* Id of the requested index pattern
*/
stateVal: string;
/**
* Determines if requested index pattern was found
*/
stateValFound: boolean;
}
export function findIndexPatternById(
indexPatterns: IndexPatternSavedObject[],
id: string
): IndexPatternSavedObject | undefined {
if (!Array.isArray(indexPatterns) || !id) {
return;
}
return indexPatterns.find((o) => o.id === id);
}
/**
* Checks if the given defaultIndex exists and returns
* the first available index pattern id if not
*/
export function getFallbackIndexPatternId(
indexPatterns: IndexPatternSavedObject[],
defaultIndex: string = ''
): string {
if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) {
return defaultIndex;
}
return indexPatterns && indexPatterns[0]?.id ? indexPatterns[0].id : '';
}
/**
* A given index pattern id is checked for existence and a fallback is provided if it doesn't exist
* The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid
* the first entry of the given list of Indexpatterns is used
*/
export function getIndexPatternId(
id: string = '',
indexPatterns: IndexPatternSavedObject[] = [],
defaultIndex: string = ''
): string {
if (!id || !findIndexPatternById(indexPatterns, id)) {
return getFallbackIndexPatternId(indexPatterns, defaultIndex);
}
return id;
}
/**
* Function to load the given index pattern by id, providing a fallback if it doesn't exist
*/
export async function loadIndexPattern(
id: string,
indexPatterns: IndexPatternsService,
config: IUiSettingsClient
): Promise<IndexPatternData> {
const indexPatternList = ((await indexPatterns.getCache()) as unknown) as IndexPatternSavedObject[];
const actualId = getIndexPatternId(id, indexPatternList, config.get('defaultIndex'));
return {
list: indexPatternList || [],
loaded: await indexPatterns.get(actualId),
stateVal: id,
stateValFound: !!id && actualId === id,
};
}
/**
* Function used in the discover controller to message the user about the state of the current
* index pattern
*/
export function resolveIndexPattern(
ip: IndexPatternData,
searchSource: SearchSource,
toastNotifications: ToastsStart
) {
const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip;
const ownIndexPattern = searchSource.getOwnField('index');
if (ownIndexPattern && !stateVal) {
return ownIndexPattern;
}
if (stateVal && !stateValFound) {
const warningTitle = i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', {
defaultMessage: '{stateVal} is not a configured index pattern ID',
values: {
stateVal: `"${stateVal}"`,
},
});
if (ownIndexPattern) {
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', {
defaultMessage:
'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})',
values: {
ownIndexPatternTitle: ownIndexPattern.title,
ownIndexPatternId: ownIndexPattern.id,
},
}),
});
return ownIndexPattern;
}
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', {
defaultMessage:
'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})',
values: {
loadedIndexPatternTitle: loadedIndexPattern.title,
loadedIndexPatternId: loadedIndexPattern.id,
},
}),
});
}
return loadedIndexPattern;
}

View file

@ -0,0 +1,51 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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';
describe('updateSearchSource', () => {
test('updates a given search source', async () => {
const searchSourceMock = createSearchSourceMock({});
const sampleSize = 250;
const result = updateSearchSource(searchSourceMock, {
indexPattern: indexPatternMock,
services: ({
data: dataPluginMock.createStartContract(),
uiSettings: ({
get: (key: string) => {
if (key === SAMPLE_SIZE_SETTING) {
return sampleSize;
}
return false;
},
} as unknown) as IUiSettingsClient,
} as unknown) as DiscoverServices,
sort: [] as SortOrder[],
});
expect(result.getField('index')).toEqual(indexPatternMock);
expect(result.getField('size')).toEqual(sampleSize);
});
});

View file

@ -0,0 +1,54 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getSortForSearchSource } from '../angular/doc_table';
import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common';
import { IndexPattern, ISearchSource } from '../../../../data/common/';
import { SortOrder } from '../../saved_searches/types';
import { DiscoverServices } from '../../build_services';
/**
* Helper function to update the given searchSource before fetching/sharing/persisting
*/
export function updateSearchSource(
searchSource: ISearchSource,
{
indexPattern,
services,
sort,
}: {
indexPattern: IndexPattern;
services: DiscoverServices;
sort: SortOrder[];
}
) {
const { uiSettings, data } = services;
const usedSort = getSortForSearchSource(
sort,
indexPattern,
uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
);
searchSource
.setField('index', indexPattern)
.setField('size', uiSettings.get(SAMPLE_SIZE_SETTING))
.setField('sort', usedSort)
.setField('query', data.query.queryString.getQuery() || null)
.setField('filter', data.query.filterManager.getFilters());
return searchSource;
}

View file

@ -17,18 +17,21 @@
* under the License.
*/
import { ISearchSource } from '../../../data/public';
import { SearchSource } from '../../../data/public';
import { SavedObjectSaveOpts } from '../../../saved_objects/public';
export type SortOrder = [string, string];
export interface SavedSearch {
readonly id: string;
title: string;
searchSource: ISearchSource;
searchSource: SearchSource;
description?: string;
columns: string[];
sort: SortOrder[];
destroy: () => void;
save: (saveOptions: SavedObjectSaveOpts) => Promise<string>;
lastSavedTitle?: string;
copyOnSave?: boolean;
}
export interface SavedSearchLoader {
get: (id: string) => Promise<SavedSearch>;