[Discover] Integration of EuiDataGrid (#67259) (#86871)

Co-authored-by: Michail Yasonik <michail.yasonik@elastic.co>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
Co-authored-by: Dave Snider <dave.snider@gmail.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
Co-authored-by: cchaos <caroline.horn@elastic.co>

Co-authored-by: Matthias Wilhelm <matthias.wilhelm@elastic.co>
Co-authored-by: Michail Yasonik <michail.yasonik@elastic.co>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
Co-authored-by: Dave Snider <dave.snider@gmail.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
Co-authored-by: cchaos <caroline.horn@elastic.co>
This commit is contained in:
Maja Grubic 2020-12-23 13:56:36 +00:00 committed by GitHub
parent 9f202aaf29
commit 755b042d87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 3158 additions and 86 deletions

View file

@ -263,6 +263,10 @@ Hides the "Time" column in *Discover* and in all saved searches on dashboards.
Highlights results in *Discover* and saved searches on dashboards. Highlighting
slows requests when working on big documents.
[[doctable-legacy]]`doc_table:legacy`::
Control the way the Discover's table looks and works. Set this property to `true` to revert to the legacy implementation.
[float]
[[kibana-ml-settings]]
==== Machine learning

View file

@ -1,5 +1,19 @@
@include euiHeaderAffordForFixed;
.euiDataGrid__restrictBody {
.headerGlobalNav,
.kbnQueryBar {
display: none;
}
}
.euiDataGrid__restrictBody.euiBody--headerIsFixed {
.euiFlyout {
top: 0;
height: 100%;
}
}
.chrHeaderHelpMenu__version {
text-transform: none;
}

View file

@ -27,4 +27,5 @@ export const FIELDS_LIMIT_SETTING = 'fields:popularLimit';
export const CONTEXT_DEFAULT_SIZE_SETTING = 'context:defaultSize';
export const CONTEXT_STEP_SETTING = 'context:step';
export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields';
export const DOC_TABLE_LEGACY = 'doc_table:legacy';
export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch';

View file

@ -22,29 +22,40 @@ import { IndexPattern } from '../../../data/common';
import { indexPatterns } from '../../../data/public';
const fields = [
{
name: '_source',
type: '_source',
scripted: false,
filterable: false,
aggregatable: false,
},
{
name: '_index',
type: 'string',
scripted: false,
filterable: true,
aggregatable: false,
},
{
name: 'message',
type: 'string',
scripted: false,
filterable: false,
aggregatable: false,
},
{
name: 'extension',
type: 'string',
scripted: false,
filterable: true,
aggregatable: true,
},
{
name: 'bytes',
type: 'number',
scripted: false,
filterable: true,
aggregatable: true,
},
{
name: 'scripted',
@ -62,16 +73,21 @@ const indexPattern = ({
id: 'the-index-pattern-id',
title: 'the-index-pattern-title',
metaFields: ['_index', '_score'],
formatField: jest.fn(),
flattenHit: undefined,
formatHit: jest.fn((hit) => hit._source),
fields,
getComputedFields: () => ({}),
getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }),
getSourceFiltering: () => ({}),
getFieldByName: () => ({}),
timeFieldName: '',
docvalueFields: [],
} as unknown) as IndexPattern;
indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields);
indexPattern.isTimeBased = () => !!indexPattern.timeFieldName;
indexPattern.formatField = (hit: Record<string, any>, fieldName: string) => {
return fieldName === '_source' ? hit._source : indexPattern.flattenHit(hit)[fieldName];
};
export const indexPatternMock = indexPattern;

View file

@ -24,7 +24,6 @@ import moment from 'moment';
import dateMath from '@elastic/datemath';
import { i18n } from '@kbn/i18n';
import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state';
import { RequestAdapter } from '../../../../inspector/public';
import {
connectToQueryState,
@ -35,6 +34,7 @@ import {
import { getSortArray } from './doc_table';
import * as columnActions from './doc_table/actions/columns';
import indexTemplateLegacy from './discover_legacy.html';
import indexTemplateGrid from './discover_datagrid.html';
import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util';
import { discoverResponseHandler } from './response_handler';
import {
@ -124,7 +124,9 @@ app.config(($routeProvider) => {
};
const discoverRoute = {
...defaults,
template: indexTemplateLegacy,
template: getServices().uiSettings.get('doc_table:legacy', true)
? indexTemplateLegacy
: indexTemplateGrid,
reloadOnSearch: false,
resolve: {
savedObjects: function ($route, Promise) {
@ -340,6 +342,8 @@ function discoverController($element, $route, $scope, $timeout, Promise, uiCapab
$scope.minimumVisibleRows = 50;
$scope.fetchStatus = fetchStatuses.UNINITIALIZED;
$scope.showSaveQuery = uiCapabilities.discover.saveQuery;
$scope.showTimeCol =
!config.get('doc_table:hideTimeColumn', false) && $scope.indexPattern.timeFieldName;
let abortController;
$scope.$on('$destroy', () => {
@ -414,7 +418,7 @@ function discoverController($element, $route, $scope, $timeout, Promise, uiCapab
const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery();
const sort = getSortArray(savedSearch.sort, $scope.indexPattern);
return {
const defaultState = {
query,
sort: !sort.length
? getDefaultSort($scope.indexPattern, config.get(SORT_DEFAULT_ORDER_SETTING, 'desc'))
@ -427,6 +431,11 @@ function discoverController($element, $route, $scope, $timeout, Promise, uiCapab
interval: 'auto',
filters: _.cloneDeep($scope.searchSource.getOwnField('filter')),
};
if (savedSearch.grid) {
defaultState.grid = savedSearch.grid;
}
return defaultState;
}
$scope.state.index = $scope.indexPattern.id;
@ -440,6 +449,8 @@ function discoverController($element, $route, $scope, $timeout, Promise, uiCapab
indexPatternList: $route.current.locals.savedObjects.ip.list,
config: config,
setHeaderActionMenu: getHeaderActionMenuMounter(),
filterManager,
setAppState,
data,
};
@ -783,6 +794,17 @@ function discoverController($element, $route, $scope, $timeout, Promise, uiCapab
const columns = columnActions.moveColumn($scope.state.columns, columnName, newIndex);
setAppState({ columns });
};
$scope.setColumns = function setColumns(columns) {
// remove first element of columns if it's the configured timeFieldName, which is prepended automatically
const actualColumns =
$scope.indexPattern.timeFieldName && $scope.indexPattern.timeFieldName === columns[0]
? columns.slice(1)
: columns;
$scope.state = { ...$scope.state, columns: actualColumns };
setAppState({ columns: actualColumns });
};
async function setupVisualization() {
// If no timefield has been specified we don't create a histogram of messages
if (!getTimeField()) return;

View file

@ -0,0 +1,31 @@
<discover-app>
<discover
fetch="fetch"
fetch-counter="fetchCounter"
fetch-error="fetchError"
field-counts="fieldCounts"
histogram-data="histogramData"
hits="hits"
index-pattern="indexPattern"
on-add-column="addColumn"
on-add-filter="filterQuery"
on-change-interval="changeInterval"
on-remove-column="removeColumn"
on-set-columns="setColumns"
on-sort="setSortOrder"
opts="opts"
reset-query="resetQuery"
result-state="resultState"
rows="rows"
search-source="searchSource"
set-index-pattern="setIndexPattern"
show-save-query="showSaveQuery"
state="state"
time-filter-update-handler="timefilterUpdateHandler"
time-range="timeRange"
top-nav-menu="topNavMenu"
update-query="updateQuery"
update-saved-query-id="updateSavedQueryId"
>
</discover>
</discover-app>

View file

@ -1,6 +1,5 @@
<discover-app>
<discover-legacy
add-column="addColumn"
fetch="fetch"
fetch-counter="fetchCounter"
fetch-error="fetchError"
@ -9,6 +8,7 @@
hits="hits"
index-pattern="indexPattern"
minimum-visible-rows="minimumVisibleRows"
on-add-column="addColumn"
on-add-filter="filterQuery"
on-move-column="moveColumn"
on-change-interval="changeInterval"
@ -20,7 +20,6 @@
reset-query="resetQuery"
result-state="resultState"
rows="rows"
saved-search="savedSearch"
search-source="searchSource"
set-index-pattern="setIndexPattern"
show-save-query="showSaveQuery"

View file

@ -36,6 +36,7 @@ import {
SearchSessionInfoProvider,
} from '../../../../data/public';
import { migrateLegacyQuery } from '../helpers/migrate_legacy_query';
import { DiscoverGridSettings } from '../components/discover_grid/types';
import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from '../../url_generator';
export interface AppState {
@ -47,6 +48,10 @@ export interface AppState {
* Array of applied filters
*/
filters?: Filter[];
/**
* Data Grid related state
*/
grid?: DiscoverGridSettings;
/**
* id of the used index pattern
*/

View file

@ -0,0 +1,52 @@
/*
* 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 { Discover } from './discover';
export function createDiscoverDirective(reactDirective: any) {
return reactDirective(Discover, [
['fetch', { watchDepth: 'reference' }],
['fetchCounter', { watchDepth: 'reference' }],
['fetchError', { watchDepth: 'reference' }],
['fieldCounts', { watchDepth: 'reference' }],
['histogramData', { watchDepth: 'reference' }],
['hits', { watchDepth: 'reference' }],
['indexPattern', { watchDepth: 'reference' }],
['onAddColumn', { watchDepth: 'reference' }],
['onAddFilter', { watchDepth: 'reference' }],
['onChangeInterval', { watchDepth: 'reference' }],
['onRemoveColumn', { watchDepth: 'reference' }],
['onSetColumns', { watchDepth: 'reference' }],
['onSort', { watchDepth: 'reference' }],
['opts', { watchDepth: 'reference' }],
['resetQuery', { watchDepth: 'reference' }],
['resultState', { watchDepth: 'reference' }],
['rows', { watchDepth: 'reference' }],
['searchSource', { watchDepth: 'reference' }],
['setColumns', { watchDepth: 'reference' }],
['setIndexPattern', { watchDepth: 'reference' }],
['showSaveQuery', { watchDepth: 'reference' }],
['state', { watchDepth: 'reference' }],
['timefilterUpdateHandler', { watchDepth: 'reference' }],
['timeRange', { watchDepth: 'reference' }],
['topNavMenu', { watchDepth: 'reference' }],
['updateQuery', { watchDepth: 'reference' }],
['updateSavedQueryId', { watchDepth: 'reference' }],
]);
}

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 * as React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { DiscoverGrid, DiscoverGridProps } from './discover_grid/discover_grid';
import { getServices } from '../../kibana_services';
export const DataGridMemoized = React.memo((props: DiscoverGridProps) => (
<DiscoverGrid {...props} />
));
export function DiscoverGridEmbeddable(props: DiscoverGridProps) {
return (
<I18nProvider>
<DataGridMemoized {...props} services={getServices()} />
</I18nProvider>
);
}
/**
* this is just needed for the embeddable
*/
export function createDiscoverGridDirective(reactDirective: any) {
return reactDirective(DiscoverGridEmbeddable, [
['columns', { watchDepth: 'collection' }],
['indexPattern', { watchDepth: 'reference' }],
['onAddColumn', { watchDepth: 'reference', wrapApply: false }],
['onFilter', { watchDepth: 'reference', wrapApply: false }],
['onRemoveColumn', { watchDepth: 'reference', wrapApply: false }],
['onSetColumns', { watchDepth: 'reference', wrapApply: false }],
['onSort', { watchDepth: 'reference', wrapApply: false }],
['rows', { watchDepth: 'collection' }],
['sampleSize', { watchDepth: 'reference' }],
['searchDescription', { watchDepth: 'reference' }],
['searchTitle', { watchDepth: 'reference' }],
['settings', { watchDepth: 'reference' }],
['showTimeCol', { watchDepth: 'value' }],
['sort', { watchDepth: 'value' }],
]);
}

View file

@ -21,7 +21,6 @@ import { DiscoverLegacy } from './discover_legacy';
export function createDiscoverLegacyDirective(reactDirective: any) {
return reactDirective(DiscoverLegacy, [
['addColumn', { watchDepth: 'reference' }],
['fetch', { watchDepth: 'reference' }],
['fetchCounter', { watchDepth: 'reference' }],
['fetchError', { watchDepth: 'reference' }],
@ -30,6 +29,7 @@ export function createDiscoverLegacyDirective(reactDirective: any) {
['hits', { watchDepth: 'reference' }],
['indexPattern', { watchDepth: 'reference' }],
['minimumVisibleRows', { watchDepth: 'reference' }],
['onAddColumn', { watchDepth: 'reference' }],
['onAddFilter', { watchDepth: 'reference' }],
['onChangeInterval', { watchDepth: 'reference' }],
['onMoveColumn', { watchDepth: 'reference' }],

View file

@ -35,6 +35,10 @@ discover-app {
}
}
.dscPageContent {
border: $euiBorderThin;
}
.dscPageContent,
.dscPageContent__inner {
height: 100%;
@ -46,6 +50,7 @@ discover-app {
.dscResultCount {
padding: $euiSizeS;
min-height: $euiSize * 3;
@include euiBreakpoint('xs', 's') {
.dscResultCount__toggle {
@ -76,6 +81,13 @@ discover-app {
padding: $euiSizeS;
}
// new slimmer layout for data grid
.dscHistogramGrid {
display: flex;
height: $euiSize * 8;
padding: $euiSizeS $euiSizeS 0 $euiSizeS;
}
.dscTable {
// SASSTODO: add a monospace modifier to the doc-table component
.kbnDocTable__row {

View file

@ -0,0 +1,321 @@
/*
* 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 './discover.scss';
import React, { useState, useRef } from 'react';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHideFor,
EuiHorizontalRule,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import classNames from 'classnames';
import { HitsCounter } from './hits_counter';
import { TimechartHeader } from './timechart_header';
import { getServices } from '../../kibana_services';
import { DiscoverUninitialized, DiscoverHistogram } from '../angular/directives';
import { DiscoverNoResults } from './no_results';
import { LoadingSpinner } from './loading_spinner/loading_spinner';
import { search } from '../../../../data/public';
import {
DiscoverSidebarResponsive,
DiscoverSidebarResponsiveProps,
} from './sidebar/discover_sidebar_responsive';
import { DiscoverProps } from './discover_legacy';
import { SortPairArr } from '../angular/doc_table/lib/get_sort';
import { DiscoverGrid, DiscoverGridProps } from './discover_grid/discover_grid';
export const SidebarMemoized = React.memo((props: DiscoverSidebarResponsiveProps) => (
<DiscoverSidebarResponsive {...props} />
));
export const DataGridMemoized = React.memo((props: DiscoverGridProps) => (
<DiscoverGrid {...props} />
));
export function Discover({
fetch,
fetchCounter,
fetchError,
fieldCounts,
histogramData,
hits,
indexPattern,
onAddColumn,
onAddFilter,
onChangeInterval,
onRemoveColumn,
onSetColumns,
onSort,
opts,
resetQuery,
resultState,
rows,
searchSource,
setIndexPattern,
showSaveQuery,
state,
timefilterUpdateHandler,
timeRange,
topNavMenu,
updateQuery,
updateSavedQueryId,
}: DiscoverProps) {
const scrollableDesktop = useRef<HTMLDivElement>(null);
const collapseIcon = useRef<HTMLButtonElement>(null);
const [toggleOn, toggleChart] = useState(true);
const [isSidebarClosed, setIsSidebarClosed] = useState(false);
const services = getServices();
const { TopNavMenu } = services.navigation.ui;
const { trackUiMetric } = services;
const { savedSearch, indexPatternList, config } = opts;
const bucketAggConfig = opts.chartAggConfigs?.aggs[1];
const bucketInterval =
bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)
? bucketAggConfig.buckets?.getInterval()
: undefined;
const contentCentered = resultState === 'uninitialized';
const showTimeCol = !config.get('doc_table:hideTimeColumn', false) && indexPattern.timeFieldName;
const columns =
state.columns &&
state.columns.length > 0 &&
// check if all columns where removed except the configured timeField (this can't be removed)
!(state.columns.length === 1 && state.columns[0] === indexPattern.timeFieldName)
? state.columns
: ['_source'];
// if columns include _source this is considered as default view, so you can't remove columns
// until you add a column using Discover's sidebar
const defaultColumns = columns.includes('_source');
return (
<I18nProvider>
<EuiPage className="dscPage" data-fetch-counter={fetchCounter}>
<TopNavMenu
appName="discover"
config={topNavMenu}
indexPatterns={[indexPattern]}
onQuerySubmit={updateQuery}
onSavedQueryIdChange={updateSavedQueryId}
query={state.query}
setMenuMountPoint={opts.setHeaderActionMenu}
savedQueryId={state.savedQuery}
screenTitle={savedSearch.title}
showDatePicker={indexPattern.isTimeBased()}
showSaveQuery={showSaveQuery}
showSearchBar={true}
useDefaultBehaviors={true}
/>
<EuiPageBody className="dscPageBody" aria-describedby="savedSearchTitle">
<h1 id="savedSearchTitle" className="euiScreenReaderOnly">
{savedSearch.title}
</h1>
<EuiFlexGroup className="dscPageBody__contents" gutterSize="none">
<EuiFlexItem grow={false}>
<SidebarMemoized
columns={state.columns || []}
fieldCounts={fieldCounts}
hits={rows}
indexPatternList={indexPatternList}
onAddField={onAddColumn}
onAddFilter={onAddFilter}
onRemoveField={onRemoveColumn}
selectedIndexPattern={searchSource && searchSource.getField('index')}
services={services}
setIndexPattern={setIndexPattern}
isClosed={isSidebarClosed}
trackUiMetric={trackUiMetric}
/>
</EuiFlexItem>
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={isSidebarClosed ? 'menuRight' : 'menuLeft'}
iconSize="m"
size="s"
onClick={() => setIsSidebarClosed(!isSidebarClosed)}
data-test-subj="collapseSideBarButton"
aria-controls="discover-sidebar"
aria-expanded={isSidebarClosed ? 'false' : 'true'}
aria-label={i18n.translate('discover.toggleSidebarAriaLabel', {
defaultMessage: 'Toggle sidebar',
})}
buttonRef={collapseIcon}
/>
</EuiFlexItem>
</EuiHideFor>
<EuiFlexItem className="dscPageContent__wrapper">
<EuiPageContent
verticalPosition={contentCentered ? 'center' : undefined}
horizontalPosition={contentCentered ? 'center' : undefined}
paddingSize="none"
className={classNames('dscPageContent', {
'dscPageContent--centered': contentCentered,
})}
>
{resultState === 'none' && (
<DiscoverNoResults
timeFieldName={opts.timefield}
queryLanguage={state.query?.language || ''}
data={opts.data}
error={fetchError}
/>
)}
{resultState === 'uninitialized' && <DiscoverUninitialized onRefresh={fetch} />}
{resultState === 'loading' && <LoadingSpinner />}
{resultState === 'ready' && (
<EuiFlexGroup
className="dscPageContent__inner"
direction="column"
alignItems="stretch"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false} className="dscResultCount">
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem
grow={false}
className="dscResuntCount__title eui-textTruncate eui-textNoWrap"
>
<HitsCounter
hits={hits > 0 ? hits : 0}
showResetButton={!!(savedSearch && savedSearch.id)}
onResetQuery={resetQuery}
/>
</EuiFlexItem>
{toggleOn && (
<EuiFlexItem className="dscResultCount__actions">
<TimechartHeader
dateFormat={opts.config.get('dateFormat')}
timeRange={timeRange}
options={search.aggs.intervalOptions}
onChangeInterval={onChangeInterval}
stateInterval={state.interval || ''}
bucketInterval={bucketInterval}
/>
</EuiFlexItem>
)}
<EuiFlexItem className="dscResultCount__toggle" grow={false}>
<EuiButtonEmpty
size="xs"
iconType={toggleOn ? 'eyeClosed' : 'eye'}
onClick={() => {
toggleChart(!toggleOn);
}}
>
{toggleOn
? i18n.translate('discover.hideChart', {
defaultMessage: 'Hide chart',
})
: i18n.translate('discover.showChart', {
defaultMessage: 'Show chart',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{toggleOn && opts.timefield && (
<EuiFlexItem grow={false}>
<section
aria-label={i18n.translate(
'discover.histogramOfFoundDocumentsAriaLabel',
{
defaultMessage: 'Histogram of found documents',
}
)}
className="dscTimechart"
>
{opts.chartAggConfigs && histogramData && rows.length !== 0 && (
<div className="dscHistogramGrid" data-test-subj="discoverChart">
<DiscoverHistogram
chartData={histogramData}
timefilterUpdateHandler={timefilterUpdateHandler}
/>
</div>
)}
</section>
<EuiSpacer size="s" />
</EuiFlexItem>
)}
<EuiHorizontalRule margin="none" />
<EuiFlexItem className="eui-yScroll">
<section
className="dscTable eui-yScroll"
aria-labelledby="documentsAriaLabel"
ref={scrollableDesktop}
tabIndex={-1}
>
<h2 className="euiScreenReaderOnly" id="documentsAriaLabel">
<FormattedMessage
id="discover.documentsAriaLabel"
defaultMessage="Documents"
/>
</h2>
{rows && rows.length && (
<div className="dscDiscoverGrid">
<DataGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={columns}
defaultColumns={defaultColumns}
indexPattern={indexPattern}
rows={rows}
sort={(state.sort as SortPairArr[]) || []}
sampleSize={opts.sampleSize}
searchDescription={opts.savedSearch.description}
searchTitle={opts.savedSearch.lastSavedTitle}
showTimeCol={Boolean(showTimeCol)}
services={services}
settings={state.grid}
onAddColumn={onAddColumn}
onFilter={onAddFilter}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
onSort={onSort}
onResize={(colSettings: { columnId: string; width: number }) => {
const grid = { ...state.grid } || {};
const newColumns = { ...grid.columns } || {};
newColumns[colSettings.columnId] = {
width: colSettings.width,
};
const newGrid = { ...grid, columns: newColumns };
opts.setAppState({ grid: newGrid });
}}
/>
</div>
)}
</section>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiPageContent>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageBody>
</EuiPage>
</I18nProvider>
);
}

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.
*/
// data types
export const kibanaJSON = 'kibana-json';
export const geoPoint = 'geo-point';
export const unknownType = 'unknown';
export const gridStyle = {
border: 'all',
fontSize: 's',
cellPadding: 's',
rowHover: 'none',
};
export const pageSizeArr = [25, 50, 100];
export const defaultPageSize = 25;
export const toolbarVisibility = {
showColumnSelector: {
allowHide: false,
allowReorder: true,
},
showStyleSelector: false,
};

View file

@ -0,0 +1,68 @@
.dscDiscoverGrid {
width: 100%;
max-width: 100%;
height: 100%;
overflow: hidden;
.euiDataGrid__controls {
border: none;
border-bottom: $euiBorderThin;
}
.euiDataGridRowCell:first-of-type,
.euiDataGrid--headerShade.euiDataGrid--bordersAll .euiDataGridHeaderCell:first-of-type {
border-left: none;
border-right: none;
}
.euiDataGridRowCell:last-of-type,
.euiDataGridHeaderCell:last-of-type {
border-right: none;
}
}
.dscDiscoverGrid__footer {
background-color: $euiColorLightShade;
padding: $euiSize / 2 $euiSize;
margin-top: $euiSize / 4;
text-align: center;
}
.dscTable__flyoutHeader {
white-space: nowrap;
}
// We only truncate if the cell is not a control column.
.euiDataGridHeader {
.euiDataGridHeaderCell__content {
@include euiTextTruncate;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
}
.euiDataGridHeaderCell__popover {
flex-grow: 0;
flex-basis: auto;
width: auto;
padding-left: $euiSizeXS;
}
}
.euiDataGridRowCell--numeric {
text-align: right;
}
.euiDataGrid__noResults {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 0 100%;
text-align: center;
height: 100%;
width: 100%;
}
.dscFormatSource {
@include euiTextTruncate;
}

View file

@ -0,0 +1,336 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import './discover_grid.scss';
import {
EuiDataGridSorting,
EuiDataGridStyle,
EuiDataGridProps,
EuiDataGrid,
EuiIcon,
EuiScreenReaderOnly,
EuiSpacer,
EuiText,
htmlIdGenerator,
} from '@elastic/eui';
import { IndexPattern } from '../../../kibana_services';
import { DocViewFilterFn, ElasticSearchHit } from '../../doc_views/doc_views_types';
import { getPopoverContents, getSchemaDetectors } from './discover_grid_schema';
import { DiscoverGridFlyout } from './discover_grid_flyout';
import { DiscoverGridContext } from './discover_grid_context';
import { getRenderCellValueFn } from './get_render_cell_value';
import { DiscoverGridSettings } from './types';
import { SortPairArr } from '../../angular/doc_table/lib/get_sort';
import {
getEuiGridColumns,
getLeadControlColumns,
getVisibleColumns,
} from './discover_grid_columns';
import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './constants';
import { DiscoverServices } from '../../../build_services';
interface SortObj {
id: string;
direction: string;
}
export interface DiscoverGridProps {
/**
* Determines which element labels the grid for ARIA
*/
ariaLabelledBy: string;
/**
* Determines which columns are displayed
*/
columns: string[];
/**
* Determines whether the given columns are the default ones, so parts of the document
* are displayed (_source) with limited actions (cannor move, remove columns)
* Implemented for matching with legacy behavior
*/
defaultColumns: boolean;
/**
* The used index pattern
*/
indexPattern: IndexPattern;
/**
* Function used to add a column in the document flyout
*/
onAddColumn: (column: string) => void;
/**
* Function to add a filter in the grid cell or document flyout
*/
onFilter: DocViewFilterFn;
/**
* Function used in the grid header and flyout to remove a column
* @param column
*/
onRemoveColumn: (column: string) => void;
/**
* Function triggered when a column is resized by the user
*/
onResize?: (colSettings: { columnId: string; width: number }) => void;
/**
* Function to set all columns
*/
onSetColumns: (columns: string[]) => void;
/**
* function to change sorting of the documents
*/
onSort: (sort: string[][]) => void;
/**
* Array of documents provided by Elasticsearch
*/
rows?: ElasticSearchHit[];
/**
* The max size of the documents returned by Elasticsearch
*/
sampleSize: number;
/**
* Grid display settings persisted in Elasticsearch (e.g. column width)
*/
settings?: DiscoverGridSettings;
/**
* Saved search description
*/
searchDescription?: string;
/**
* Saved search title
*/
searchTitle?: string;
/**
* Discover plugin services
*/
services: DiscoverServices;
/**
* Determines whether the time columns should be displayed (legacy settings)
*/
showTimeCol: boolean;
/**
* Current sort setting
*/
sort: SortPairArr[];
}
export const EuiDataGridMemoized = React.memo((props: EuiDataGridProps) => {
return <EuiDataGrid {...props} />;
});
export const DiscoverGrid = ({
ariaLabelledBy,
columns,
defaultColumns,
indexPattern,
onAddColumn,
onFilter,
onRemoveColumn,
onResize,
onSetColumns,
onSort,
rows,
sampleSize,
searchDescription,
searchTitle,
services,
settings,
showTimeCol,
sort,
}: DiscoverGridProps) => {
const [expanded, setExpanded] = useState<ElasticSearchHit | undefined>(undefined);
/**
* Pagination
*/
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize });
const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]);
const pageCount = useMemo(() => Math.ceil(rowCount / pagination.pageSize), [
rowCount,
pagination,
]);
const isOnLastPage = pagination.pageIndex === pageCount - 1;
const paginationObj = useMemo(() => {
const onChangeItemsPerPage = (pageSize: number) =>
setPagination((paginationData) => ({ ...paginationData, pageSize }));
const onChangePage = (pageIndex: number) =>
setPagination((paginationData) => ({ ...paginationData, pageIndex }));
return {
onChangeItemsPerPage,
onChangePage,
pageIndex: pagination.pageIndex > pageCount - 1 ? 0 : pagination.pageIndex,
pageSize: pagination.pageSize,
pageSizeOptions: pageSizeArr,
};
}, [pagination, pageCount]);
/**
* Sorting
*/
const sortingColumns = useMemo(() => sort.map(([id, direction]) => ({ id, direction })), [sort]);
const onTableSort = useCallback(
(sortingColumnsData) => {
onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction]));
},
[onSort]
);
/**
* Cell rendering
*/
const renderCellValue = useMemo(
() =>
getRenderCellValueFn(
indexPattern,
rows,
rows ? rows.map((hit) => indexPattern.flattenHit(hit)) : []
),
[rows, indexPattern]
);
/**
* Render variables
*/
const showDisclaimer = rowCount === sampleSize && isOnLastPage;
const randomId = useMemo(() => htmlIdGenerator()(), []);
const euiGridColumns = useMemo(
() => getEuiGridColumns(columns, settings, indexPattern, showTimeCol, defaultColumns),
[columns, indexPattern, showTimeCol, settings, defaultColumns]
);
const schemaDetectors = useMemo(() => getSchemaDetectors(), []);
const popoverContents = useMemo(() => getPopoverContents(), []);
const columnsVisibility = useMemo(
() => ({
visibleColumns: getVisibleColumns(columns, indexPattern, showTimeCol) as string[],
setVisibleColumns: (newColumns: string[]) => {
onSetColumns(newColumns);
},
}),
[columns, indexPattern, showTimeCol, onSetColumns]
);
const sorting = useMemo(() => ({ columns: sortingColumns, onSort: onTableSort }), [
sortingColumns,
onTableSort,
]);
const lead = useMemo(() => getLeadControlColumns(), []);
if (!rowCount) {
return (
<div className="euiDataGrid__noResults">
<EuiText size="xs" color="subdued">
<EuiIcon type="discoverApp" size="m" color="subdued" />
<EuiSpacer size="s" />
<FormattedMessage id="discover.noResultsFound" defaultMessage="No results found" />
</EuiText>
</div>
);
}
return (
<DiscoverGridContext.Provider
value={{
expanded,
setExpanded,
rows: rows || [],
onFilter,
indexPattern,
isDarkMode: services.uiSettings.get('theme:darkMode'),
}}
>
<>
<EuiDataGridMemoized
aria-describedby={randomId}
aria-labelledby={ariaLabelledBy}
columns={euiGridColumns}
columnVisibility={columnsVisibility}
data-test-subj="docTable"
gridStyle={gridStyle as EuiDataGridStyle}
leadingControlColumns={lead}
onColumnResize={(col: { columnId: string; width: number }) => {
if (onResize) {
onResize(col);
}
}}
pagination={paginationObj}
popoverContents={popoverContents}
renderCellValue={renderCellValue}
rowCount={rowCount}
schemaDetectors={schemaDetectors}
sorting={sorting as EuiDataGridSorting}
toolbarVisibility={
defaultColumns
? {
...toolbarVisibility,
showColumnSelector: false,
}
: toolbarVisibility
}
/>
{showDisclaimer && (
<p className="dscDiscoverGrid__footer">
<FormattedMessage
id="discover.howToSeeOtherMatchingDocumentsDescriptionGrid"
defaultMessage="These are the first {sampleSize} documents matching your search, refine your search to see others."
values={{ sampleSize }}
/>
<a href={`#${ariaLabelledBy}`}>
<FormattedMessage id="discover.backToTopLinkText" defaultMessage="Back to top." />
</a>
</p>
)}
{searchTitle && (
<EuiScreenReaderOnly>
<p id={String(randomId)}>
{searchDescription ? (
<FormattedMessage
id="discover.searchGenerationWithDescriptionGrid"
defaultMessage="Table generated by search {searchTitle} ({searchDescription})"
values={{ searchTitle, searchDescription }}
/>
) : (
<FormattedMessage
id="discover.searchGenerationWithDescription"
defaultMessage="Table generated by search {searchTitle}"
values={{ searchTitle }}
/>
)}
</p>
</EuiScreenReaderOnly>
)}
{expanded && (
<DiscoverGridFlyout
indexPattern={indexPattern}
hit={expanded}
columns={columns}
onFilter={onFilter}
onRemoveColumn={onRemoveColumn}
onAddColumn={onAddColumn}
onClose={() => setExpanded(undefined)}
services={services}
/>
)}
</>
</DiscoverGridContext.Provider>
);
};

View file

@ -0,0 +1,80 @@
/*
* 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 { mountWithIntl } from '@kbn/test/jest';
import { findTestSubject } from '@elastic/eui/lib/test';
import { FilterInBtn, FilterOutBtn } from './discover_grid_cell_actions';
import { DiscoverGridContext } from './discover_grid_context';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { esHits } from '../../../__mocks__/es_hits';
import { EuiButton } from '@elastic/eui';
describe('Discover cell actions ', function () {
it('triggers filter function when FilterInBtn is clicked', async () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<FilterInBtn
Component={(props: any) => <EuiButton {...props} />}
rowIndex={1}
columnId={'extension'}
isExpanded={false}
closePopover={jest.fn()}
/>
</DiscoverGridContext.Provider>
);
const button = findTestSubject(component, 'filterForButton');
await button.simulate('click');
expect(contextMock.onFilter).toHaveBeenCalledWith('extension', 'jpg', '+');
});
it('triggers filter function when FilterOutBtn is clicked', async () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<FilterOutBtn
Component={(props: any) => <EuiButton {...props} />}
rowIndex={1}
columnId={'extension'}
isExpanded={false}
closePopover={jest.fn()}
/>
</DiscoverGridContext.Provider>
);
const button = findTestSubject(component, 'filterOutButton');
await button.simulate('click');
expect(contextMock.onFilter).toHaveBeenCalledWith('extension', 'jpg', '-');
});
});

View file

@ -0,0 +1,97 @@
/*
* 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, { useContext } from 'react';
import { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IndexPatternField } from '../../../../../data/common/index_patterns/fields';
import { DiscoverGridContext } from './discover_grid_context';
export const FilterInBtn = ({
Component,
rowIndex,
columnId,
}: EuiDataGridColumnCellActionProps) => {
const context = useContext(DiscoverGridContext);
const buttonTitle = i18n.translate('discover.grid.filterForAria', {
defaultMessage: 'Filter for this {value}',
values: { value: columnId },
});
return (
<Component
onClick={() => {
const row = context.rows[rowIndex];
const flattened = context.indexPattern.flattenHit(row);
if (flattened) {
context.onFilter(columnId, flattened[columnId], '+');
}
}}
iconType="plusInCircle"
aria-label={buttonTitle}
title={buttonTitle}
data-test-subj="filterForButton"
>
{i18n.translate('discover.grid.filterFor', {
defaultMessage: 'Filter for',
})}
</Component>
);
};
export const FilterOutBtn = ({
Component,
rowIndex,
columnId,
}: EuiDataGridColumnCellActionProps) => {
const context = useContext(DiscoverGridContext);
const buttonTitle = i18n.translate('discover.grid.filterOutAria', {
defaultMessage: 'Filter out this {value}',
values: { value: columnId },
});
return (
<Component
onClick={() => {
const row = context.rows[rowIndex];
const flattened = context.indexPattern.flattenHit(row);
if (flattened) {
context.onFilter(columnId, flattened[columnId], '-');
}
}}
iconType="minusInCircle"
aria-label={buttonTitle}
title={buttonTitle}
data-test-subj="filterOutButton"
>
{i18n.translate('discover.grid.filterOut', {
defaultMessage: 'Filter out',
})}
</Component>
);
};
export function buildCellActions(field: IndexPatternField) {
if (!field.aggregatable && !field.searchable) {
return undefined;
}
return [FilterInBtn, FilterOutBtn];
}

View file

@ -0,0 +1,154 @@
/*
* 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 { indexPatternMock } from '../../../__mocks__/index_pattern';
import { getEuiGridColumns } from './discover_grid_columns';
import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield';
describe('Discover grid columns ', function () {
it('returns eui grid columns without time column', async () => {
const actual = getEuiGridColumns(['extension', 'message'], {}, indexPatternMock, false, false);
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": undefined,
"display": undefined,
"id": "extension",
"isSortable": undefined,
"schema": "unknown",
},
Object {
"actions": Object {
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": undefined,
"display": undefined,
"id": "message",
"isSortable": undefined,
"schema": "unknown",
},
]
`);
});
it('returns eui grid columns without time column showing default columns', async () => {
const actual = getEuiGridColumns(
['extension', 'message'],
{},
indexPatternWithTimefieldMock,
false,
true
);
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"showHide": false,
"showMoveLeft": false,
"showMoveRight": false,
},
"cellActions": undefined,
"display": undefined,
"id": "extension",
"isSortable": undefined,
"schema": "unknown",
},
Object {
"actions": Object {
"showHide": false,
"showMoveLeft": false,
"showMoveRight": false,
},
"cellActions": undefined,
"display": undefined,
"id": "message",
"isSortable": undefined,
"schema": "unknown",
},
]
`);
});
it('returns eui grid columns with time column', async () => {
const actual = getEuiGridColumns(
['extension', 'message'],
{},
indexPatternWithTimefieldMock,
true,
false
);
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"showHide": false,
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": undefined,
"display": "Time (timestamp)",
"id": "timestamp",
"initialWidth": 180,
"isSortable": undefined,
"schema": "unknown",
},
Object {
"actions": Object {
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": undefined,
"display": undefined,
"id": "extension",
"isSortable": undefined,
"schema": "unknown",
},
Object {
"actions": Object {
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": undefined,
"display": undefined,
"id": "message",
"isSortable": undefined,
"schema": "unknown",
},
]
`);
});
});

View file

@ -0,0 +1,122 @@
/*
* 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 { EuiDataGridColumn, EuiScreenReaderOnly } from '@elastic/eui';
import { ExpandButton } from './discover_grid_expand_button';
import { DiscoverGridSettings } from './types';
import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns';
import { buildCellActions } from './discover_grid_cell_actions';
import { getSchemaByKbnType } from './discover_grid_schema';
export function getLeadControlColumns() {
return [
{
id: 'openDetails',
width: 32,
headerCellRender: () => (
<EuiScreenReaderOnly>
<span>
{i18n.translate('discover.controlColumnHeader', {
defaultMessage: 'Control column',
})}
</span>
</EuiScreenReaderOnly>
),
rowCellRender: ExpandButton,
},
];
}
export function buildEuiGridColumn(
columnName: string,
columnWidth: number | undefined = 0,
indexPattern: IndexPattern,
defaultColumns: boolean
) {
const timeString = i18n.translate('discover.timeLabel', {
defaultMessage: 'Time',
});
const indexPatternField = indexPattern.getFieldByName(columnName);
const column: EuiDataGridColumn = {
id: columnName,
schema: getSchemaByKbnType(indexPatternField?.type),
isSortable: indexPatternField?.sortable,
display: indexPatternField?.displayName,
actions: {
showHide:
defaultColumns || columnName === indexPattern.timeFieldName
? false
: {
label: i18n.translate('discover.removeColumnLabel', {
defaultMessage: 'Remove column',
}),
iconType: 'cross',
},
showMoveLeft: !defaultColumns,
showMoveRight: !defaultColumns,
},
cellActions: indexPatternField ? buildCellActions(indexPatternField) : [],
};
if (column.id === indexPattern.timeFieldName) {
column.display = `${timeString} (${indexPattern.timeFieldName})`;
column.initialWidth = 180;
}
if (columnWidth > 0) {
column.initialWidth = Number(columnWidth);
}
return column;
}
export function getEuiGridColumns(
columns: string[],
settings: DiscoverGridSettings | undefined,
indexPattern: IndexPattern,
showTimeCol: boolean,
defaultColumns: boolean
) {
const timeFieldName = indexPattern.timeFieldName;
const getColWidth = (column: string) => settings?.columns?.[column]?.width ?? 0;
if (showTimeCol && indexPattern.timeFieldName && !columns.find((col) => col === timeFieldName)) {
const usedColumns = [indexPattern.timeFieldName, ...columns];
return usedColumns.map((column) =>
buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns)
);
}
return columns.map((column) =>
buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns)
);
}
export function getVisibleColumns(
columns: string[],
indexPattern: IndexPattern,
showTimeCol: boolean
) {
const timeFieldName = indexPattern.timeFieldName;
if (showTimeCol && !columns.find((col) => col === timeFieldName)) {
return [timeFieldName, ...columns];
}
return columns;
}

View file

@ -0,0 +1,34 @@
/*
* 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 { DocViewFilterFn, ElasticSearchHit } from '../../doc_views/doc_views_types';
import { IndexPattern } from '../../../kibana_services';
export interface GridContext {
expanded: ElasticSearchHit | undefined;
setExpanded: (hit: ElasticSearchHit | undefined) => void;
rows: ElasticSearchHit[];
onFilter: DocViewFilterFn;
indexPattern: IndexPattern;
isDarkMode: boolean;
}
const defaultContext = ({} as unknown) as GridContext;
export const DiscoverGridContext = React.createContext<GridContext>(defaultContext);

View file

@ -0,0 +1,106 @@
/*
* 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 { mountWithIntl } from '@kbn/test/jest';
import { findTestSubject } from '@elastic/eui/lib/test';
import { ExpandButton } from './discover_grid_expand_button';
import { DiscoverGridContext } from './discover_grid_context';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { esHits } from '../../../__mocks__/es_hits';
describe('Discover grid view button ', function () {
it('when no document is expanded, setExpanded is called with current document', async () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<ExpandButton
rowIndex={0}
setCellProps={jest.fn()}
columnId="test"
isExpanded={false}
isDetails={false}
isExpandable={false}
/>
</DiscoverGridContext.Provider>
);
const button = findTestSubject(component, 'docTableExpandToggleColumn');
await button.simulate('click');
expect(contextMock.setExpanded).toHaveBeenCalledWith(esHits[0]);
});
it('when the current document is expanded, setExpanded is called with undefined', async () => {
const contextMock = {
expanded: esHits[0],
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<ExpandButton
rowIndex={0}
setCellProps={jest.fn()}
columnId="test"
isExpanded={false}
isDetails={false}
isExpandable={false}
/>
</DiscoverGridContext.Provider>
);
const button = findTestSubject(component, 'docTableExpandToggleColumn');
await button.simulate('click');
expect(contextMock.setExpanded).toHaveBeenCalledWith(undefined);
});
it('when another document is expanded, setExpanded is called with the current document', async () => {
const contextMock = {
expanded: esHits[0],
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<ExpandButton
rowIndex={1}
setCellProps={jest.fn()}
columnId="test"
isExpanded={false}
isDetails={false}
isExpandable={false}
/>
</DiscoverGridContext.Provider>
);
const button = findTestSubject(component, 'docTableExpandToggleColumn');
await button.simulate('click');
expect(contextMock.setExpanded).toHaveBeenCalledWith(esHits[1]);
});
});

View file

@ -0,0 +1,62 @@
/*
* 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, { useContext, useEffect } from 'react';
import { EuiButtonIcon, EuiDataGridCellValueElementProps, EuiToolTip } from '@elastic/eui';
import themeDark from '@elastic/eui/dist/eui_theme_dark.json';
import themeLight from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { DiscoverGridContext } from './discover_grid_context';
/**
* Button to expand a given row
*/
export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueElementProps) => {
const { expanded, setExpanded, rows, isDarkMode } = useContext(DiscoverGridContext);
const current = rows[rowIndex];
useEffect(() => {
if (expanded && current && expanded._id === current._id) {
setCellProps({
style: {
backgroundColor: isDarkMode ? themeDark.euiColorHighlight : themeLight.euiColorHighlight,
},
});
} else {
setCellProps({ style: undefined });
}
}, [expanded, current, setCellProps, isDarkMode]);
const isCurrentRowExpanded = current === expanded;
const buttonLabel = i18n.translate('discover.grid.viewDoc', {
defaultMessage: 'Toggle dialog with details',
});
return (
<EuiToolTip content={buttonLabel} delay="long">
<EuiButtonIcon
size="s"
iconSize="s"
aria-label={buttonLabel}
data-test-subj="docTableExpandToggleColumn"
onClick={() => setExpanded(isCurrentRowExpanded ? undefined : current)}
color={isCurrentRowExpanded ? 'primary' : 'subdued'}
iconType={isCurrentRowExpanded ? 'minimize' : 'expand'}
isSelected={isCurrentRowExpanded}
/>
</EuiToolTip>
);
};

View file

@ -0,0 +1,143 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiButtonEmpty,
EuiText,
EuiSpacer,
EuiPortal,
} from '@elastic/eui';
import { DocViewer } from '../doc_viewer/doc_viewer';
import { IndexPattern } from '../../../kibana_services';
import { DocViewFilterFn, ElasticSearchHit } from '../../doc_views/doc_views_types';
import { DiscoverServices } from '../../../build_services';
import { getContextUrl } from '../../helpers/get_context_url';
interface Props {
columns: string[];
hit: ElasticSearchHit;
indexPattern: IndexPattern;
onAddColumn: (column: string) => void;
onClose: () => void;
onFilter: DocViewFilterFn;
onRemoveColumn: (column: string) => void;
services: DiscoverServices;
}
/**
* Flyout displaying an expanded Elasticsearch document
*/
export function DiscoverGridFlyout({
hit,
indexPattern,
columns,
onFilter,
onClose,
onRemoveColumn,
onAddColumn,
services,
}: Props) {
return (
<EuiPortal>
<EuiFlyout onClose={onClose} size="m" data-test-subj="docTableDetailsFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle
size="s"
className="dscTable__flyoutHeader"
data-test-subj="docTableRowDetailsTitle"
>
<h2>
{i18n.translate('discover.grid.tableRow.detailHeading', {
defaultMessage: 'Expanded document',
})}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup responsive={false} gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="s">
<strong>
{i18n.translate('discover.grid.tableRow.viewText', {
defaultMessage: 'View:',
})}
</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="document"
href={`#/doc/${indexPattern.id}/${hit._index}?id=${encodeURIComponent(
hit._id as string
)}`}
data-test-subj="docTableRowAction"
>
{i18n.translate('discover.grid.tableRow.viewSingleDocumentLinkTextSimple', {
defaultMessage: 'Single document',
})}
</EuiButtonEmpty>
</EuiFlexItem>
{indexPattern.isTimeBased() && indexPattern.id && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="documents"
href={getContextUrl(hit._id, indexPattern.id, columns, services.filterManager)}
data-test-subj="docTableRowAction"
>
{i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple', {
defaultMessage: 'Surrounding documents',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<DocViewer
hit={hit}
columns={columns}
indexPattern={indexPattern}
filter={(mapping, value, mode) => {
onFilter(mapping, value, mode);
onClose();
}}
onRemoveColumn={(columnName: string) => {
onRemoveColumn(columnName);
onClose();
}}
onAddColumn={(columnName: string) => {
onAddColumn(columnName);
onClose();
}}
/>
</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
);
}

View file

@ -0,0 +1,103 @@
/*
* 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, { ReactNode } from 'react';
import { EuiCodeBlock } from '@elastic/eui';
import { geoPoint, kibanaJSON, unknownType } from './constants';
import { KBN_FIELD_TYPES } from '../../../../../data/common';
export function getSchemaByKbnType(kbnType: string | undefined) {
// Default DataGrid schemas: boolean, numeric, datetime, json, currency, string
switch (kbnType) {
case KBN_FIELD_TYPES.IP:
case KBN_FIELD_TYPES.GEO_SHAPE:
case KBN_FIELD_TYPES.NUMBER:
return 'numeric';
case KBN_FIELD_TYPES.BOOLEAN:
return 'boolean';
case KBN_FIELD_TYPES.STRING:
return 'string';
case KBN_FIELD_TYPES.DATE:
return 'datetime';
case KBN_FIELD_TYPES._SOURCE:
return kibanaJSON;
case KBN_FIELD_TYPES.GEO_POINT:
return geoPoint;
default:
return unknownType;
}
}
export function getSchemaDetectors() {
return [
{
type: kibanaJSON,
detector() {
return 0; // this schema is always explicitly defined
},
sortTextAsc: '',
sortTextDesc: '',
icon: '',
color: '',
},
{
type: unknownType,
detector() {
return 0; // this schema is always explicitly defined
},
sortTextAsc: '',
sortTextDesc: '',
icon: '',
color: '',
},
{
type: geoPoint,
detector() {
return 0; // this schema is always explicitly defined
},
sortTextAsc: '',
sortTextDesc: '',
icon: 'tokenGeo',
},
];
}
/**
* Returns custom popover content for certain schemas
*/
export function getPopoverContents() {
return {
[geoPoint]: ({ children }: { children: ReactNode }) => {
return <span className="geo-point">{children}</span>;
},
[unknownType]: ({ children }: { children: ReactNode }) => {
return (
<EuiCodeBlock isCopyable language="json" paddingSize="none" transparentBackground={true}>
{children}
</EuiCodeBlock>
);
},
[kibanaJSON]: ({ children }: { children: ReactNode }) => {
return (
<EuiCodeBlock isCopyable language="json" paddingSize="none" transparentBackground={true}>
{children}
</EuiCodeBlock>
);
},
};
}

View file

@ -0,0 +1,132 @@
/*
* 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 { shallow } from 'enzyme';
import { getRenderCellValueFn } from './get_render_cell_value';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
const rows = [
{
_id: '1',
_index: 'test',
_type: 'test',
_score: 1,
_source: { bytes: 100 },
},
];
describe('Discover grid cell rendering', function () {
it('renders bytes column correctly', () => {
const DiscoverGridCellValue = getRenderCellValueFn(
indexPatternMock,
rows,
rows.map((row) => indexPatternMock.flattenHit(row))
);
const component = shallow(
<DiscoverGridCellValue
rowIndex={0}
columnId="bytes"
isDetails={false}
isExpanded={false}
isExpandable={true}
setCellProps={jest.fn()}
/>
);
expect(component.html()).toMatchInlineSnapshot(`"<span>100</span>"`);
});
it('renders _source column correctly', () => {
const DiscoverGridCellValue = getRenderCellValueFn(
indexPatternMock,
rows,
rows.map((row) => indexPatternMock.flattenHit(row))
);
const component = shallow(
<DiscoverGridCellValue
rowIndex={0}
columnId="_source"
isDetails={false}
isExpanded={false}
isExpandable={true}
setCellProps={jest.fn()}
/>
);
expect(component.html()).toMatchInlineSnapshot(
`"<dl class=\\"euiDescriptionList euiDescriptionList--inline euiDescriptionList--compressed\\"><dt class=\\"euiDescriptionList__title\\">bytes</dt><dd class=\\"euiDescriptionList__description\\">100</dd></dl>"`
);
});
it('renders _source column correctly when isDetails is set to true', () => {
const DiscoverGridCellValue = getRenderCellValueFn(
indexPatternMock,
rows,
rows.map((row) => indexPatternMock.flattenHit(row))
);
const component = shallow(
<DiscoverGridCellValue
rowIndex={0}
columnId="_source"
isDetails={true}
isExpanded={false}
isExpandable={true}
setCellProps={jest.fn()}
/>
);
expect(component.html()).toMatchInlineSnapshot(`
"<span>{
&quot;bytes&quot;: 100
}</span>"
`);
});
it('renders correctly when invalid row is given', () => {
const DiscoverGridCellValue = getRenderCellValueFn(
indexPatternMock,
rows,
rows.map((row) => indexPatternMock.flattenHit(row))
);
const component = shallow(
<DiscoverGridCellValue
rowIndex={1}
columnId="bytes"
isDetails={false}
isExpanded={false}
isExpandable={true}
setCellProps={jest.fn()}
/>
);
expect(component.html()).toMatchInlineSnapshot(`"<span>-</span>"`);
});
it('renders correctly when invalid column is given', () => {
const DiscoverGridCellValue = getRenderCellValueFn(
indexPatternMock,
rows,
rows.map((row) => indexPatternMock.flattenHit(row))
);
const component = shallow(
<DiscoverGridCellValue
rowIndex={0}
columnId="bytes-invalid"
isDetails={false}
isExpanded={false}
isExpandable={true}
setCellProps={jest.fn()}
/>
);
expect(component.html()).toMatchInlineSnapshot(`"<span>-</span>"`);
});
});

View file

@ -0,0 +1,116 @@
/*
* 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, { Fragment, useContext, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import themeLight from '@elastic/eui/dist/eui_theme_light.json';
import themeDark from '@elastic/eui/dist/eui_theme_dark.json';
import {
EuiDataGridCellValueElementProps,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { IndexPattern } from '../../../kibana_services';
import { ElasticSearchHit } from '../../doc_views/doc_views_types';
import { DiscoverGridContext } from './discover_grid_context';
export const getRenderCellValueFn = (
indexPattern: IndexPattern,
rows: ElasticSearchHit[] | undefined,
rowsFlattened: Array<Record<string, unknown>>
) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => {
const row = rows ? (rows[rowIndex] as Record<string, unknown>) : undefined;
const rowFlattened = rowsFlattened
? (rowsFlattened[rowIndex] as Record<string, unknown>)
: undefined;
const field = indexPattern.fields.getByName(columnId);
const ctx = useContext(DiscoverGridContext);
useEffect(() => {
if (ctx.expanded && row && ctx.expanded._id === row._id) {
setCellProps({
style: {
backgroundColor: ctx.isDarkMode
? themeDark.euiColorHighlight
: themeLight.euiColorHighlight,
},
});
} else {
setCellProps({ style: undefined });
}
}, [ctx, row, setCellProps]);
if (typeof row === 'undefined' || typeof rowFlattened === 'undefined') {
return <span>-</span>;
}
if (field && field.type === '_source') {
if (isDetails) {
// nicely formatted JSON for the expanded view
return <span>{JSON.stringify(row[columnId], null, 2)}</span>;
}
const formatted = indexPattern.formatHit(row);
return (
<EuiDescriptionList type="inline" compressed>
{Object.keys(formatted).map((key) => (
<Fragment key={key}>
<EuiDescriptionListTitle>{key}</EuiDescriptionListTitle>
<EuiDescriptionListDescription dangerouslySetInnerHTML={{ __html: formatted[key] }} />
</Fragment>
))}
</EuiDescriptionList>
);
}
if (!field?.type && rowFlattened && typeof rowFlattened[columnId] === 'object') {
if (isDetails) {
// nicely formatted JSON for the expanded view
return <span>{JSON.stringify(rowFlattened[columnId], null, 2)}</span>;
}
return <span>{JSON.stringify(rowFlattened[columnId])}</span>;
}
if (field?.type === 'geo_point' && rowFlattened && rowFlattened[columnId]) {
const valueFormatted = rowFlattened[columnId] as { lat: number; lon: number };
return (
<div>
{i18n.translate('discover.latitudeAndLongitude', {
defaultMessage: 'Lat: {lat} Lon: {lon}',
values: {
lat: valueFormatted?.lat,
lon: valueFormatted?.lon,
},
})}
</div>
);
}
const valueFormatted = indexPattern.formatField(row, columnId);
if (typeof valueFormatted === 'undefined') {
return <span>-</span>;
}
return (
// eslint-disable-next-line react/no-danger
<span dangerouslySetInnerHTML={{ __html: indexPattern.formatField(row, columnId) }} />
);
};

View file

@ -0,0 +1,29 @@
/*
* 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.
*/
/**
* User configurable state of data grid, persisted in saved search
*/
export interface DiscoverGridSettings {
columns?: Record<string, DiscoverGridSettingsColumn>;
}
export interface DiscoverGridSettingsColumn {
width?: number;
}

View file

@ -67,7 +67,6 @@ function getProps(indexPattern: IndexPattern) {
} as unknown) as DiscoverServices;
return {
addColumn: jest.fn(),
fetch: jest.fn(),
fetchCounter: 0,
fetchError: undefined,
@ -75,6 +74,7 @@ function getProps(indexPattern: IndexPattern) {
hits: esHits.length,
indexPattern,
minimumVisibleRows: 10,
onAddColumn: jest.fn(),
onAddFilter: jest.fn(),
onChangeInterval: jest.fn(),
onMoveColumn: jest.fn(),

View file

@ -63,46 +63,161 @@ import {
import { DocViewFilterFn, ElasticSearchHit } from '../doc_views/doc_views_types';
export interface DiscoverProps {
addColumn: (column: string) => void;
/**
* Function to fetch documents from Elasticsearch
*/
fetch: () => void;
/**
* Counter how often data was fetched (used for testing)
*/
fetchCounter: number;
/**
* Error in case of a failing document fetch
*/
fetchError?: Error;
/**
* Statistics by fields calculated using the fetched documents
*/
fieldCounts: Record<string, number>;
/**
* Histogram aggregation data
*/
histogramData?: Chart;
/**
* Number of documents found by recent fetch
*/
hits: number;
/**
* Current IndexPattern
*/
indexPattern: IndexPattern;
/**
* Value needed for legacy "infinite" loading functionality
* Determins how much records are rendered using the legacy table
* Increased when scrolling down
*/
minimumVisibleRows: number;
/**
* Function to add a column to state
*/
onAddColumn: (column: string) => void;
/**
* Function to add a filter to state
*/
onAddFilter: DocViewFilterFn;
/**
* Function to change the used time interval of the date histogram
*/
onChangeInterval: (interval: string) => void;
/**
* Function to move a given column to a given index, used in legacy table
*/
onMoveColumn: (columns: string, newIdx: number) => void;
/**
* Function to remove a given column from state
*/
onRemoveColumn: (column: string) => void;
/**
* Function to replace columns in state
*/
onSetColumns: (columns: string[]) => void;
/**
* Function to scroll down the legacy table to the bottom
*/
onSkipBottomButtonClick: () => void;
/**
* Function to change sorting of the table, triggers a fetch
*/
onSort: (sort: string[][]) => void;
opts: {
/**
* Date histogram aggregation config
*/
chartAggConfigs?: AggConfigs;
/**
* Client of uiSettings
*/
config: IUiSettingsClient;
/**
* Data plugin
*/
data: DataPublicPluginStart;
fixedScroll: (el: HTMLElement) => void;
/**
* Data plugin filter manager
*/
filterManager: FilterManager;
/**
* List of available index patterns
*/
indexPatternList: Array<SavedObject<IndexPatternAttributes>>;
/**
* The number of documents that can be displayed in the table/grid
*/
sampleSize: number;
/**
* Current instance of SavedSearch
*/
savedSearch: SavedSearch;
/**
* Function to set the header menu
*/
setHeaderActionMenu: (menuMount: MountPoint | undefined) => void;
/**
* Timefield of the currently used index pattern
*/
timefield: string;
/**
* Function to set the current state
*/
setAppState: (state: Partial<AppState>) => void;
};
/**
* Function to reset the current query
*/
resetQuery: () => void;
/**
* Current state of the actual query, one of 'uninitialized', 'loading' ,'ready', 'none'
*/
resultState: string;
/**
* Array of document of the recent successful search request
*/
rows: ElasticSearchHit[];
/**
* Instance of SearchSource, the high level search API
*/
searchSource: ISearchSource;
/**
* Function to change the current index pattern
*/
setIndexPattern: (id: string) => void;
/**
* Determines whether the user should be able to use the save query feature
*/
showSaveQuery: boolean;
/**
* Current app state of URL
*/
state: AppState;
/**
* Function to update the time filter
*/
timefilterUpdateHandler: (ranges: { from: number; to: number }) => void;
/**
* Currently selected time range
*/
timeRange?: { from: string; to: string };
/**
* Menu data of top navigation (New, save ...)
*/
topNavMenu: TopNavMenuData[];
/**
* Function to update the actual query
*/
updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void;
/**
* Function to update the actual savedQuery id
*/
updateSavedQueryId: (savedQueryId?: string) => void;
}
@ -114,7 +229,6 @@ export const SidebarMemoized = React.memo((props: DiscoverSidebarResponsiveProps
));
export function DiscoverLegacy({
addColumn,
fetch,
fetchCounter,
fieldCounts,
@ -123,6 +237,7 @@ export function DiscoverLegacy({
hits,
indexPattern,
minimumVisibleRows,
onAddColumn,
onAddFilter,
onChangeInterval,
onMoveColumn,
@ -192,7 +307,7 @@ export function DiscoverLegacy({
fieldCounts={fieldCounts}
hits={rows}
indexPatternList={indexPatternList}
onAddField={addColumn}
onAddField={onAddColumn}
onAddFilter={onAddFilter}
onRemoveField={onRemoveColumn}
selectedIndexPattern={searchSource && searchSource.getField('index')}
@ -206,6 +321,8 @@ export function DiscoverLegacy({
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={isSidebarClosed ? 'menuRight' : 'menuLeft'}
iconSize="m"
size="s"
onClick={() => setIsSidebarClosed(!isSidebarClosed)}
data-test-subj="collapseSideBarButton"
aria-controls="discover-sidebar"
@ -335,7 +452,7 @@ export function DiscoverLegacy({
sort={state.sort || []}
searchDescription={opts.savedSearch.description}
searchTitle={opts.savedSearch.lastSavedTitle}
onAddColumn={addColumn}
onAddColumn={onAddColumn}
onFilter={onAddFilter}
onMoveColumn={onMoveColumn}
onRemoveColumn={onRemoveColumn}

View file

@ -6,6 +6,7 @@ exports[`Render <DocViewer/> with 3 different tabs 1`] = `
>
<EuiTabbedContent
autoFocus="initial"
size="s"
tabs={
Array [
Object {

View file

@ -1,5 +1,4 @@
.kbnDocViewerTable {
margin-top: $euiSizeS;
@include euiBreakpoint('xs', 's') {
table-layout: fixed;
}

View file

@ -57,7 +57,7 @@ export function DocViewer(renderProps: DocViewRenderProps) {
return (
<div className="kbnDocViewer">
<EuiTabbedContent tabs={tabs} />
<EuiTabbedContent size="s" tabs={tabs} />
</div>
);
}

View file

@ -31,7 +31,7 @@ exports[`FieldName renders a geo field 1`] = `
</div>
`;
exports[`FieldName renders a number field by providing a field record, useShortDots is set to false 1`] = `
exports[`FieldName renders a number field by providing a field record 1`] = `
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow"
>

View file

@ -27,7 +27,7 @@ test('FieldName renders a string field by providing fieldType and fieldName', ()
expect(component).toMatchSnapshot();
});
test('FieldName renders a number field by providing a field record, useShortDots is set to false', () => {
test('FieldName renders a number field by providing a field record', () => {
const component = render(<FieldName fieldName={'test.test.test'} fieldType={'number'} />);
expect(component).toMatchSnapshot();
});

View file

@ -56,7 +56,6 @@ function getComponent({
}: {
selected?: boolean;
showDetails?: boolean;
useShortDots?: boolean;
field?: IndexPatternField;
}) {
const indexPattern = getStubIndexPattern(

View file

@ -19,51 +19,58 @@
import { groupFields } from './group_fields';
import { getDefaultFieldFilter } from './field_filter';
import { IndexPatternField } from '../../../../../../data/common/index_patterns/fields';
const fields = [
{
name: 'category',
type: 'string',
esTypes: ['text'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'currency',
type: 'string',
esTypes: ['keyword'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'customer_birth_date',
type: 'date',
esTypes: ['date'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
];
const fieldCounts = {
category: 1,
currency: 1,
customer_birth_date: 1,
};
describe('group_fields', function () {
it('should group fields in selected, popular, unpopular group', function () {
const fields = [
{
name: 'category',
type: 'string',
esTypes: ['text'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'currency',
type: 'string',
esTypes: ['keyword'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'customer_birth_date',
type: 'date',
esTypes: ['date'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
];
const fieldCounts = {
category: 1,
currency: 1,
customer_birth_date: 1,
};
const fieldFilterState = getDefaultFieldFilter();
const actual = groupFields(fields as any, ['currency'], 5, fieldCounts, fieldFilterState);
const actual = groupFields(
fields as IndexPatternField[],
['currency'],
5,
fieldCounts,
fieldFilterState
);
expect(actual).toMatchInlineSnapshot(`
Object {
"popular": Array [
@ -111,4 +118,34 @@ describe('group_fields', function () {
}
`);
});
it('should sort selected fields by columns order ', function () {
const fieldFilterState = getDefaultFieldFilter();
const actual1 = groupFields(
fields as IndexPatternField[],
['customer_birth_date', 'currency', 'unknown'],
5,
fieldCounts,
fieldFilterState
);
expect(actual1.selected.map((field) => field.name)).toEqual([
'customer_birth_date',
'currency',
'unknown',
]);
const actual2 = groupFields(
fields as IndexPatternField[],
['currency', 'customer_birth_date', 'unknown'],
5,
fieldCounts,
fieldFilterState
);
expect(actual2.selected.map((field) => field.name)).toEqual([
'currency',
'customer_birth_date',
'unknown',
]);
});
});

View file

@ -70,6 +70,15 @@ export function groupFields(
result.unpopular.push(field);
}
}
// add columns, that are not part of the index pattern, to be removeable
for (const column of columns) {
if (!result.selected.find((field) => field.name === column)) {
result.selected.push({ name: column, displayName: column } as IndexPatternField);
}
}
result.selected.sort((a, b) => {
return columns.indexOf(a.name) - columns.indexOf(b.name);
});
return result;
}

View file

@ -36,6 +36,7 @@ import {
import { Container, Embeddable } from '../../../../embeddable/public';
import * as columnActions from '../angular/doc_table/actions/columns';
import searchTemplate from './search_template.html';
import searchTemplateGrid from './search_template_datagrid.html';
import { ISearchEmbeddable, SearchInput, SearchOutput } from './types';
import { SortOrder } from '../angular/doc_table/components/table_header/helpers';
import { getSortForSearchSource } from '../angular/doc_table';
@ -49,23 +50,29 @@ import {
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
import { SavedSearch } from '../..';
import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common';
import { DiscoverGridSettings } from '../components/discover_grid/types';
import { DiscoverServices } from '../../build_services';
import { ElasticSearchHit } from '../doc_views/doc_views_types';
import { getDefaultSort } from '../angular/doc_table/lib/get_default_sort';
interface SearchScope extends ng.IScope {
columns?: string[];
settings?: DiscoverGridSettings;
description?: string;
sort?: SortOrder[];
sharedItemTitle?: string;
inspectorAdapters?: Adapters;
setSortOrder?: (sortPair: SortOrder[]) => void;
setColumns?: (columns: string[]) => void;
removeColumn?: (column: string) => void;
addColumn?: (column: string) => void;
moveColumn?: (column: string, index: number) => void;
filter?: (field: IFieldType, value: string[], operator: string) => void;
hits?: any[];
hits?: ElasticSearchHit[];
indexPattern?: IndexPattern;
totalHitCount?: number;
isLoading?: boolean;
showTimeCol?: boolean;
}
interface SearchEmbeddableConfig {
@ -77,6 +84,7 @@ interface SearchEmbeddableConfig {
indexPatterns?: IndexPattern[];
editable: boolean;
filterManager: FilterManager;
services: DiscoverServices;
}
export class SearchEmbeddable
@ -95,6 +103,7 @@ export class SearchEmbeddable
public readonly type = SEARCH_EMBEDDABLE_TYPE;
private filterManager: FilterManager;
private abortController?: AbortController;
private services: DiscoverServices;
private prevTimeRange?: TimeRange;
private prevFilters?: Filter[];
@ -111,6 +120,7 @@ export class SearchEmbeddable
indexPatterns,
editable,
filterManager,
services,
}: SearchEmbeddableConfig,
initialInput: SearchInput,
private readonly executeTriggerActions: UiActionsStart['executeTriggerActions'],
@ -128,7 +138,7 @@ export class SearchEmbeddable
},
parent
);
this.services = services;
this.filterManager = filterManager;
this.savedSearch = savedSearch;
this.$rootScope = $rootScope;
@ -138,8 +148,8 @@ export class SearchEmbeddable
};
this.initializeSearchScope();
this.autoRefreshFetchSubscription = getServices()
.timefilter.getAutoRefreshFetch$()
this.autoRefreshFetchSubscription = this.services.timefilter
.getAutoRefreshFetch$()
.subscribe(this.fetch);
this.subscription = this.getUpdated$().subscribe(() => {
@ -167,7 +177,9 @@ export class SearchEmbeddable
if (!this.searchScope) {
throw new Error('Search scope not defined');
}
this.searchInstance = this.$compile(searchTemplate)(this.searchScope);
this.searchInstance = this.$compile(
this.services.uiSettings.get('doc_table:legacy', true) ? searchTemplate : searchTemplateGrid
)(this.searchScope);
const rootNode = angular.element(domNode);
rootNode.append(this.searchInstance);
@ -250,6 +262,15 @@ export class SearchEmbeddable
this.updateInput({ columns });
};
searchScope.setColumns = (columns: string[]) => {
this.updateInput({ columns });
};
if (this.savedSearch.grid) {
searchScope.settings = this.savedSearch.grid;
}
searchScope.showTimeCol = !this.services.uiSettings.get('doc_table:hideTimeColumn', false);
searchScope.filter = async (field, value, operator) => {
let filters = esFilters.generateFilters(
this.filterManager,
@ -286,13 +307,13 @@ export class SearchEmbeddable
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
searchSource.setField('size', getServices().uiSettings.get(SAMPLE_SIZE_SETTING));
searchSource.setField('size', this.services.uiSettings.get(SAMPLE_SIZE_SETTING));
searchSource.setField(
'sort',
getSortForSearchSource(
this.searchScope.sort,
this.searchScope.indexPattern,
getServices().uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING)
)
);

View file

@ -103,6 +103,7 @@ export class SearchEmbeddableFactory
filterManager,
editable: getServices().capabilities.discover.save as boolean,
indexPatterns: indexPattern ? [indexPattern] : [],
services: getServices(),
},
input,
executeTriggerActions,

View file

@ -1,20 +1,20 @@
<doc-table
sorting="sort"
columns="columns"
data-shared-item
data-title="{{sharedItemTitle}}"
data-description="{{description}}"
render-complete
class="panel-content"
columns="columns"
data-description="{{description}}"
data-shared-item
data-test-subj="embeddedSavedSearchDocTable"
data-title="{{sharedItemTitle}}"
filter="filter"
hits="hits"
index-pattern="indexPattern"
is-loading="isLoading"
on-add-column="addColumn"
on-change-sort-order="setSortOrder"
on-move-column="moveColumn"
on-remove-column="removeColumn"
data-test-subj="embeddedSavedSearchDocTable"
hits="hits"
index-pattern="indexPattern"
render-complete
sorting="sort"
total-hit-count="totalHitCount"
is-loading="isLoading"
>
</doc-table>

View file

@ -0,0 +1,19 @@
<discover-grid
class="dscDiscoverGrid"
columns="columns"
data-description="{{description}}"
data-shared-item
data-title="{{sharedItemTitle}}"
index-pattern="indexPattern"
on-add-column="addColumn"
on-filter="filter"
on-remove-column="removeColumn"
on-set-columns="setColumns"
on-sort="setSortOrder"
render-complete
rows="hits"
sample-size="500"
settings="settings"
show-time-col="showTimeCol"
sort="sort"
></discover-grid>

View file

@ -51,7 +51,7 @@ describe('getSharingData', () => {
"searchRequest": Object {
"body": Object {
"_source": Object {},
"fields": undefined,
"fields": Array [],
"query": Object {
"bool": Object {
"filter": Array [],
@ -68,7 +68,9 @@ describe('getSharingData', () => {
},
},
],
"stored_fields": undefined,
"stored_fields": Array [
"*",
],
},
"index": "the-index-pattern-title",
},

View file

@ -53,6 +53,9 @@ export async function persistSavedSearch(
savedSearch.columns = state.columns || [];
savedSearch.sort = (state.sort as SortOrder[]) || [];
if (state.grid) {
savedSearch.grid = state.grid;
}
try {
const id = await savedSearch.save(saveOptions);

View file

@ -41,6 +41,7 @@ import { createTableRowDirective } from './application/angular/doc_table/compone
import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory';
import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll';
import { createDocViewerDirective } from './application/angular/doc_viewer';
import { createDiscoverGridDirective } from './application/components/create_discover_grid_directive';
import { createRenderCompleteDirective } from './application/angular/directives/render_complete';
import {
initAngularBootstrap,
@ -55,6 +56,8 @@ import {
import { DiscoverStartPlugins } from './plugin';
import { getScopedHistory } from './kibana_services';
import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive';
import { createDiscoverDirective } from './application/components/create_discover_directive';
/**
* returns the main inner angular module, it contains all the parts of Angular Discover
* needs to render, so in the end the current 'kibana' angular module is no longer necessary
@ -136,7 +139,8 @@ export function initializeInnerAngularModule(
.config(watchMultiDecorator)
.run(registerListenEventListener)
.directive('renderComplete', createRenderCompleteDirective)
.directive('discoverLegacy', createDiscoverLegacyDirective);
.directive('discoverLegacy', createDiscoverLegacyDirective)
.directive('discover', createDiscoverDirective);
}
function createLocalPromiseModule() {
@ -188,6 +192,7 @@ function createDocTableModule() {
.directive('kbnTableRow', createTableRowDirective)
.directive('toolBarPagerButtons', createToolBarPagerButtonsDirective)
.directive('kbnInfiniteScroll', createInfiniteScrollDirective)
.directive('discoverGrid', createDiscoverGridDirective)
.directive('docViewer', createDocViewerDirective)
.directive('contextAppLegacy', createContextAppLegacy);
}

View file

@ -26,6 +26,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) {
description: 'text',
hits: 'integer',
columns: 'keyword',
grid: 'object',
sort: 'keyword',
version: 'integer',
};
@ -45,6 +46,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) {
description: 'text',
hits: 'integer',
columns: 'keyword',
grid: 'object',
sort: 'keyword',
version: 'integer',
},

View file

@ -19,6 +19,7 @@
import { SearchSource } from '../../../data/public';
import { SavedObjectSaveOpts } from '../../../saved_objects/public';
import { DiscoverGridSettings } from '../application/components/discover_grid/types';
export type SortOrder = [string, string];
export interface SavedSearch {
@ -28,6 +29,7 @@ export interface SavedSearch {
description?: string;
columns: string[];
sort: SortOrder[];
grid: DiscoverGridSettings;
destroy: () => void;
save: (saveOptions: SavedObjectSaveOpts) => Promise<string>;
lastSavedTitle?: string;

View file

@ -53,6 +53,7 @@ export const searchSavedObjectType: SavedObjectsType = {
},
sort: { type: 'keyword', index: false, doc_values: false },
title: { type: 'text' },
grid: { type: 'object', enabled: false },
version: { type: 'integer' },
},
},

View file

@ -33,6 +33,7 @@ import {
CONTEXT_DEFAULT_SIZE_SETTING,
CONTEXT_STEP_SETTING,
CONTEXT_TIE_BREAKER_FIELDS_SETTING,
DOC_TABLE_LEGACY,
MODIFY_COLUMNS_ON_SWITCH,
} from '../common';
@ -165,6 +166,23 @@ export const uiSettings: Record<string, UiSettingsParams> = {
category: ['discover'],
schema: schema.arrayOf(schema.string()),
},
[DOC_TABLE_LEGACY]: {
name: i18n.translate('discover.advancedSettings.docTableVersionName', {
defaultMessage: 'Use legacy table',
}),
value: true,
description: i18n.translate('discover.advancedSettings.docTableVersionDescription', {
defaultMessage:
'Discover uses a new table layout that includes better data sorting, drag-and-drop columns, and a full screen ' +
'view. Enable this option if you prefer to fall back to the legacy table.',
}),
category: ['discover'],
schema: schema.boolean(),
metric: {
type: METRIC_TYPE.CLICK,
name: 'discover:useLegacyDataGrid',
},
},
[MODIFY_COLUMNS_ON_SWITCH]: {
name: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchTitle', {
defaultMessage: 'Modify columns when changing index patterns',

View file

@ -0,0 +1,60 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardAddPanel = getService('dashboardAddPanel');
const filterBar = getService('filterBar');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const find = getService('find');
const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'timePicker', 'discover']);
describe('dashboard embeddable data grid', () => {
before(async () => {
await esArchiver.loadIfNeeded('logstash_functional');
await esArchiver.loadIfNeeded('dashboard/current/data');
await esArchiver.loadIfNeeded('dashboard/current/kibana');
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
'doc_table:legacy': false,
});
await PageObjects.common.navigateToApp('dashboard');
await filterBar.ensureFieldEditorModalIsClosed();
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setDefaultDataRange();
});
describe('saved search filters', function () {
it('are added when a cell filter is clicked', async function () {
await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search');
await find.clickByCssSelector(`[role="gridcell"]:nth-child(2)`);
await find.clickByCssSelector(`[data-test-subj="filterOutButton"]`);
await PageObjects.header.waitUntilLoadingHasFinished();
await find.clickByCssSelector(`[role="gridcell"]:nth-child(2)`);
await find.clickByCssSelector(`[data-test-subj="filterForButton"]`);
const filterCount = await filterBar.getFilterCount();
expect(filterCount).to.equal(2);
});
});
});
}

View file

@ -54,6 +54,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./empty_dashboard'));
loadTestFile(require.resolve('./url_field_formatter'));
loadTestFile(require.resolve('./embeddable_rendering'));
loadTestFile(require.resolve('./embeddable_data_grid'));
loadTestFile(require.resolve('./create_and_add_embeddables'));
loadTestFile(require.resolve('./edit_embeddable_redirects'));
loadTestFile(require.resolve('./edit_visualizations'));

View file

@ -0,0 +1,67 @@
/*
* 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 expect from '@kbn/expect';
export default function ({
getService,
getPageObjects,
}: {
getService: (service: string) => any;
getPageObjects: (pageObjects: string[]) => any;
}) {
describe('discover data grid tests', function describeDiscoverDataGrid() {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker']);
const kibanaServer = getService('kibanaServer');
const defaultSettings = { defaultIndex: 'logstash-*', 'doc_table:legacy': false };
const testSubjects = getService('testSubjects');
before(async function () {
await esArchiver.load('discover');
await esArchiver.loadIfNeeded('logstash_functional');
await kibanaServer.uiSettings.replace(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.timePicker.setDefaultAbsoluteRange();
});
after(async function () {
await kibanaServer.uiSettings.replace({ 'doc_table:legacy': true });
});
it('can add fields to the table', async function () {
const getTitles = async () =>
(await testSubjects.getVisibleText('dataGridHeader')).replace(/\s|\r?\n|\r/g, ' ');
expect(await getTitles()).to.be('Time (@timestamp) _source');
await PageObjects.discover.clickFieldListItemAdd('bytes');
expect(await getTitles()).to.be('Time (@timestamp) bytes');
await PageObjects.discover.clickFieldListItemAdd('agent');
expect(await getTitles()).to.be('Time (@timestamp) bytes agent');
await PageObjects.discover.clickFieldListItemAdd('bytes');
expect(await getTitles()).to.be('Time (@timestamp) agent');
await PageObjects.discover.clickFieldListItemAdd('agent');
expect(await getTitles()).to.be('Time (@timestamp) _source');
});
});
}

View file

@ -0,0 +1,91 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
const TEST_COLUMN_NAMES = ['@message'];
const TEST_FILTER_COLUMN_NAMES = [
['extension', 'jpg'],
['geo.src', 'IN'],
];
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const filterBar = getService('filterBar');
const dataGrid = getService('dataGrid');
const docTable = getService('docTable');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']);
const defaultSettings = { defaultIndex: 'logstash-*', 'doc_table:legacy': false };
const kibanaServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
describe('discover data grid context tests', () => {
before(async () => {
await esArchiver.loadIfNeeded('logstash_functional');
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
for (const columnName of TEST_COLUMN_NAMES) {
await PageObjects.discover.clickFieldListItemAdd(columnName);
}
for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) {
await PageObjects.discover.clickFieldListItem(columnName);
await PageObjects.discover.clickFieldListPlusFilter(columnName, value);
}
});
after(async () => {
await PageObjects.timePicker.resetDefaultAbsoluteRangeViaUiSettings();
});
it('should open the context view with the selected document as anchor', async () => {
// check the anchor timestamp in the context view
await retry.waitFor('selected document timestamp matches anchor timestamp ', async () => {
// get the timestamp of the first row
const discoverFields = await dataGrid.getFields();
const firstTimestamp = discoverFields[0][0];
// navigate to the context view
await dataGrid.clickRowToggle({ rowIndex: 0 });
const rowActions = await dataGrid.getRowActions({ rowIndex: 0 });
await rowActions[1].click();
// entering the context view (contains the legacy type)
const contextFields = await docTable.getFields();
const anchorTimestamp = contextFields[0][0];
return anchorTimestamp === firstTimestamp;
});
});
it('should open the context view with the same columns', async () => {
const columnNames = await docTable.getHeaderFields();
expect(columnNames).to.eql(['Time', ...TEST_COLUMN_NAMES]);
});
it('should open the context view with the filters disabled', async () => {
let disabledFilterCounter = 0;
for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) {
if (await filterBar.hasFilter(columnName, value, false)) {
disabledFilterCounter++;
}
}
expect(disabledFilterCounter).to.be(TEST_FILTER_COLUMN_NAMES.length);
});
});
}

View file

@ -0,0 +1,91 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const filterBar = getService('filterBar');
const dataGrid = getService('dataGrid');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'context']);
const esArchiver = getService('esArchiver');
const retry = getService('retry');
const kibanaServer = getService('kibanaServer');
const defaultSettings = { defaultIndex: 'logstash-*', 'doc_table:legacy': false };
describe('discover data grid doc link', function () {
beforeEach(async function () {
await esArchiver.loadIfNeeded('logstash_functional');
await esArchiver.loadIfNeeded('discover');
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
});
it('should open the doc view of the selected document', async function () {
// navigate to the doc view
await dataGrid.clickRowToggle({ rowIndex: 0 });
// click the open action
await retry.try(async () => {
const rowActions = await dataGrid.getRowActions({ rowIndex: 0 });
if (!rowActions.length) {
throw new Error('row actions empty, trying again');
}
await rowActions[0].click();
});
const hasDocHit = await testSubjects.exists('doc-hit');
expect(hasDocHit).to.be(true);
});
it('add filter should create an exists filter if value is null (#7189)', async function () {
await PageObjects.discover.waitUntilSearchingHasFinished();
// Filter special document
await filterBar.addFilter('agent', 'is', 'Missing/Fields');
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.try(async () => {
// navigate to the doc view
await dataGrid.clickRowToggle({ rowIndex: 0 });
const details = await dataGrid.getDetailsRow();
await dataGrid.addInclusiveFilter(details, 'referer');
await PageObjects.discover.waitUntilSearchingHasFinished();
const hasInclusiveFilter = await filterBar.hasFilter(
'referer',
'exists',
true,
false,
true
);
expect(hasInclusiveFilter).to.be(true);
await dataGrid.clickRowToggle({ rowIndex: 0 });
const detailsExcluding = await dataGrid.getDetailsRow();
await dataGrid.removeInclusiveFilter(detailsExcluding, 'referer');
await PageObjects.discover.waitUntilSearchingHasFinished();
const hasExcludeFilter = await filterBar.hasFilter('referer', 'exists', true, false, false);
expect(hasExcludeFilter).to.be(true);
});
});
});
}

View file

@ -0,0 +1,132 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dataGrid = getService('dataGrid');
const log = getService('log');
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']);
const defaultSettings = {
defaultIndex: 'logstash-*',
'doc_table:legacy': false,
};
describe('discover data grid doc table', function describeIndexTests() {
const defaultRowsLimit = 25;
before(async function () {
log.debug('load kibana index with default index pattern');
await esArchiver.load('discover');
await esArchiver.loadIfNeeded('logstash_functional');
await kibanaServer.uiSettings.replace(defaultSettings);
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
});
it('should show the first 50 rows by default', async function () {
// with the default range the number of hits is ~14000
const rows = await dataGrid.getDocTableRows();
expect(rows.length).to.be(defaultRowsLimit);
});
it('should refresh the table content when changing time window', async function () {
const initialRows = await dataGrid.getDocTableRows();
const fromTime = 'Sep 20, 2015 @ 23:00:00.000';
const toTime = 'Sep 20, 2015 @ 23:14:00.000';
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
await PageObjects.discover.waitUntilSearchingHasFinished();
const finalRows = await PageObjects.discover.getDocTableRows();
expect(finalRows.length).to.be.below(initialRows.length);
await PageObjects.timePicker.setDefaultAbsoluteRange();
});
describe('expand a document row', function () {
const rowToInspect = 1;
it('should expand the detail row when the toggle arrow is clicked', async function () {
await retry.try(async function () {
await dataGrid.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 });
const detailsEl = await dataGrid.getDetailsRows();
const defaultMessageEl = await detailsEl[0].findByTestSubject('docTableRowDetailsTitle');
expect(defaultMessageEl).to.be.ok();
await dataGrid.closeFlyout();
});
});
it('should show the detail panel actions', async function () {
await retry.try(async function () {
await dataGrid.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 });
const [surroundingActionEl, singleActionEl] = await dataGrid.getRowActions({
isAnchorRow: false,
rowIndex: rowToInspect - 1,
});
expect(surroundingActionEl).to.be.ok();
expect(singleActionEl).to.be.ok();
await dataGrid.closeFlyout();
});
});
});
describe('add and remove columns', function () {
const extraColumns = ['phpmemory', 'ip'];
afterEach(async function () {
for (const column of extraColumns) {
await PageObjects.discover.clickFieldListItemRemove(column);
await PageObjects.header.waitUntilLoadingHasFinished();
}
});
it('should add more columns to the table', async function () {
for (const column of extraColumns) {
await PageObjects.discover.clearFieldSearchInput();
await PageObjects.discover.findFieldByName(column);
await PageObjects.discover.clickFieldListItemAdd(column);
await PageObjects.header.waitUntilLoadingHasFinished();
// test the header now
const header = await dataGrid.getHeaderFields();
expect(header.join(' ')).to.have.string(column);
}
});
it('should remove columns from the table', async function () {
for (const column of extraColumns) {
await PageObjects.discover.clearFieldSearchInput();
await PageObjects.discover.findFieldByName(column);
await PageObjects.discover.clickFieldListItemAdd(column);
await PageObjects.header.waitUntilLoadingHasFinished();
}
// remove the second column
await PageObjects.discover.clickFieldListItemAdd(extraColumns[1]);
await PageObjects.header.waitUntilLoadingHasFinished();
// test that the second column is no longer there
const header = await dataGrid.getHeaderFields();
expect(header.join(' ')).to.not.have.string(extraColumns[1]);
});
});
});
}

View file

@ -0,0 +1,99 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const toasts = getService('toasts');
const queryBar = getService('queryBar');
const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']);
const defaultSettings = { defaultIndex: 'logstash-*', 'doc_table:legacy': false };
const dataGrid = getService('dataGrid');
describe('discover data grid field data tests', function describeIndexTests() {
this.tags('includeFirefox');
before(async function () {
await esArchiver.load('discover');
await esArchiver.loadIfNeeded('logstash_functional');
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
});
describe('field data', function () {
it('search php should show the correct hit count', async function () {
const expectedHitCount = '445';
await retry.try(async function () {
await queryBar.setQuery('php');
await queryBar.submitQuery();
const hitCount = await PageObjects.discover.getHitCount();
expect(hitCount).to.be(expectedHitCount);
});
});
it('the search term should be highlighted in the field data', async function () {
// marks is the style that highlights the text in yellow
const marks = await PageObjects.discover.getMarks();
expect(marks.length).to.be(25);
expect(marks.indexOf('php')).to.be(0);
});
it('search type:apache should show the correct hit count', async function () {
const expectedHitCount = '11,156';
await queryBar.setQuery('type:apache');
await queryBar.submitQuery();
await retry.try(async function tryingForTime() {
const hitCount = await PageObjects.discover.getHitCount();
expect(hitCount).to.be(expectedHitCount);
});
});
it('doc view should show Time and _source columns', async function () {
const expectedHeader = 'Time (@timestamp) _source';
const DocHeader = await dataGrid.getHeaderFields();
expect(DocHeader.join(' ')).to.be(expectedHeader);
});
it('doc view should sort ascending', async function () {
const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000';
await dataGrid.clickDocSortAsc();
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.try(async function tryingForTime() {
const rowData = await dataGrid.getFields();
expect(rowData[0][0].startsWith(expectedTimeStamp)).to.be.ok();
});
});
it('a bad syntax query should show an error message', async function () {
const expectedError =
'Expected ":", "<", "<=", ">", ">=", AND, OR, end of input, ' +
'whitespace but "(" found.';
await queryBar.setQuery('xxx(yyy))');
await queryBar.submitQuery();
const { message } = await toasts.getErrorToast();
expect(message).to.contain(expectedError);
await toasts.dismissToast();
});
});
});
}

View file

@ -131,13 +131,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should add more columns to the table', async function () {
const [column] = extraColumns;
await PageObjects.discover.findFieldByName(column);
log.debug(`add a ${column} column`);
await PageObjects.discover.clickFieldListItemAdd(column);
await PageObjects.header.waitUntilLoadingHasFinished();
// test the header now
expect(await PageObjects.discover.getDocHeader()).to.have.string(column);
for (const column of extraColumns) {
await PageObjects.discover.clearFieldSearchInput();
await PageObjects.discover.findFieldByName(column);
await PageObjects.discover.clickFieldListItemAdd(column);
await PageObjects.header.waitUntilLoadingHasFinished();
// test the header now
expect(await PageObjects.discover.getDocHeader()).to.have.string(column);
}
});
it('should remove columns from the table', async function () {

View file

@ -51,5 +51,10 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./_date_nanos'));
loadTestFile(require.resolve('./_date_nanos_mixed'));
loadTestFile(require.resolve('./_indexpattern_without_timefield'));
loadTestFile(require.resolve('./_data_grid'));
loadTestFile(require.resolve('./_data_grid_context'));
loadTestFile(require.resolve('./_data_grid_field_data'));
loadTestFile(require.resolve('./_data_grid_doc_navigation'));
loadTestFile(require.resolve('./_data_grid_doc_table'));
});
}

View file

@ -24,10 +24,15 @@ interface TabbedGridData {
columns: string[];
rows: string[][];
}
interface SelectOptions {
isAnchorRow?: boolean;
rowIndex: number;
}
export function DataGridProvider({ getService }: FtrProviderContext) {
export function DataGridProvider({ getService, getPageObjects }: FtrProviderContext) {
const find = getService('find');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common', 'header']);
class DataGrid {
async getDataGridTableData(): Promise<TabbedGridData> {
@ -103,6 +108,137 @@ export function DataGridProvider({ getService }: FtrProviderContext) {
[data-test-subj="dataGridRowCell"]:nth-of-type(${columnIndex})`
);
}
public async getFields() {
const rows = await find.allByCssSelector('.euiDataGridRow');
const result = [];
for (const row of rows) {
const cells = await row.findAllByClassName('euiDataGridRowCell__truncate');
const cellsText = [];
let cellIdx = 0;
for (const cell of cells) {
if (cellIdx > 0) {
cellsText.push(await cell.getVisibleText());
}
cellIdx++;
}
result.push(cellsText);
}
return result;
}
public async getTable(selector: string = 'docTable') {
return await testSubjects.find(selector);
}
public async getBodyRows(): Promise<WebElementWrapper[]> {
const table = await this.getTable();
return await table.findAllByTestSubject('dataGridRow');
}
public async getDocTableRows() {
const table = await this.getTable();
return await table.findAllByTestSubject('dataGridRow');
}
public async getAnchorRow(): Promise<WebElementWrapper> {
const table = await this.getTable();
return await table.findByTestSubject('~docTableAnchorRow');
}
public async getRow(options: SelectOptions): Promise<WebElementWrapper> {
return options.isAnchorRow
? await this.getAnchorRow()
: (await this.getBodyRows())[options.rowIndex];
}
public async clickRowToggle(
options: SelectOptions = { isAnchorRow: false, rowIndex: 0 }
): Promise<void> {
const row = await this.getRow(options);
const toggle = await row.findByTestSubject('~docTableExpandToggleColumn');
await toggle.click();
}
public async getDetailsRows(): Promise<WebElementWrapper[]> {
return await testSubjects.findAll('docTableDetailsFlyout');
}
public async closeFlyout() {
await testSubjects.click('euiFlyoutCloseButton');
}
public async getHeaderFields(): Promise<string[]> {
const result = await find.allByCssSelector('.euiDataGridHeaderCell__content');
const textArr = [];
let idx = 0;
for (const cell of result) {
if (idx > 0) {
textArr.push(await cell.getVisibleText());
}
idx++;
}
return Promise.resolve(textArr);
}
public async getRowActions(
options: SelectOptions = { isAnchorRow: false, rowIndex: 0 }
): Promise<WebElementWrapper[]> {
const detailsRow = (await this.getDetailsRows())[options.rowIndex];
return await detailsRow.findAllByTestSubject('~docTableRowAction');
}
public async clickDocSortAsc() {
await find.clickByCssSelector('.euiDataGridHeaderCell__button');
await find.clickByButtonText('Sort New-Old');
}
public async clickDocSortDesc() {
await find.clickByCssSelector('.euiDataGridHeaderCell__button');
await find.clickByButtonText('Sort Old-New');
}
public async getDetailsRow(): Promise<WebElementWrapper> {
const detailRows = await this.getDetailsRows();
return detailRows[0];
}
public async addInclusiveFilter(
detailsRow: WebElementWrapper,
fieldName: string
): Promise<void> {
const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName);
const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow);
await addInclusiveFilterButton.click();
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
}
public async getAddInclusiveFilterButton(
tableDocViewRow: WebElementWrapper
): Promise<WebElementWrapper> {
return await tableDocViewRow.findByTestSubject(`~addInclusiveFilterButton`);
}
public async getTableDocViewRow(
detailsRow: WebElementWrapper,
fieldName: string
): Promise<WebElementWrapper> {
return await detailsRow.findByTestSubject(`~tableDocViewRow-${fieldName}`);
}
public async getRemoveInclusiveFilterButton(
tableDocViewRow: WebElementWrapper
): Promise<WebElementWrapper> {
return await tableDocViewRow.findByTestSubject(`~removeInclusiveFilterButton`);
}
public async removeInclusiveFilter(
detailsRow: WebElementWrapper,
fieldName: string
): Promise<void> {
const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName);
const addInclusiveFilterButton = await this.getRemoveInclusiveFilterButton(tableDocViewRow);
await addInclusiveFilterButton.click();
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
}
}
return new DataGrid();