[Discover] Add source to doc viewer (#101392) (#102962)

* [Discover] Add source to doc viewer

* Updating a unit test

* Fix typescript errors

* Add unit test

* Add a functional test

* Fixing a typo

* Remove unnecessary import

* Always request fields and source

* Remove unused import

* Move initialization of SourceViewer back to setup

* Trying to get rid of null value

* Readding null

* Try to get rid of null value

* Addressing PR comments

* Return early if jsonValue is not set

* Fix loading spinner style

* Add refresh on error

* Fix error message

* Add loading indicator on an empty string

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Maja Grubic 2021-06-22 19:44:35 +01:00 committed by GitHub
parent fd15a1f5f8
commit 0e28661990
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1248 additions and 157 deletions

View file

@ -10,9 +10,10 @@ import React from 'react';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui';
import { IndexPatternsContract } from 'src/plugins/data/public';
import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search';
import { useEsDocSearch } from './use_es_doc_search';
import { getServices } from '../../../kibana_services';
import { DocViewer } from '../doc_viewer/doc_viewer';
import { ElasticRequestState } from './elastic_request_state';
export interface DocProps {
/**
@ -32,6 +33,10 @@ export interface DocProps {
* IndexPatternService to get a given index pattern by ID
*/
indexPatternService: IndexPatternsContract;
/**
* If set, will always request source, regardless of the global `fieldsFromSource` setting
*/
requestSource?: boolean;
}
export function Doc(props: DocProps) {

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export enum ElasticRequestState {
Loading,
NotFound,
Found,
Error,
NotFoundIndexPattern,
}

View file

@ -7,11 +7,12 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search';
import { buildSearchBody, useEsDocSearch } from './use_es_doc_search';
import { DocProps } from './doc';
import { Observable } from 'rxjs';
import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common';
import { IndexPattern } from 'src/plugins/data/common';
import { ElasticRequestState } from './elastic_request_state';
const mockSearchResult = new Observable();
@ -88,6 +89,36 @@ describe('Test of <Doc /> helper / hook', () => {
`);
});
test('buildSearchBody with requestSource', () => {
const indexPattern = ({
getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }),
} as unknown) as IndexPattern;
const actual = buildSearchBody('1', indexPattern, true, true);
expect(actual).toMatchInlineSnapshot(`
Object {
"body": Object {
"_source": true,
"fields": Array [
Object {
"field": "*",
"include_unmapped": "true",
},
],
"query": Object {
"ids": Object {
"values": Array [
"1",
],
},
},
"runtime_mappings": Object {},
"script_fields": Array [],
"stored_fields": Array [],
},
}
`);
});
test('buildSearchBody with runtime fields', () => {
const indexPattern = ({
getComputedFields: () => ({
@ -155,7 +186,11 @@ describe('Test of <Doc /> helper / hook', () => {
await act(async () => {
hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props });
});
expect(hook.result.current).toEqual([ElasticRequestState.Loading, null, indexPattern]);
expect(hook.result.current.slice(0, 3)).toEqual([
ElasticRequestState.Loading,
null,
indexPattern,
]);
expect(getMock).toHaveBeenCalled();
});
});

View file

@ -6,23 +6,16 @@
* Side Public License, v 1.
*/
import { useEffect, useState, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { estypes } from '@elastic/elasticsearch';
import { IndexPattern, getServices } from '../../../kibana_services';
import { getServices, IndexPattern } from '../../../kibana_services';
import { DocProps } from './doc';
import { ElasticSearchHit } from '../../doc_views/doc_views_types';
import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common';
import { ElasticRequestState } from './elastic_request_state';
type RequestBody = Pick<estypes.SearchRequest, 'body'>;
export enum ElasticRequestState {
Loading,
NotFound,
Found,
Error,
NotFoundIndexPattern,
}
/**
* helper function to build a query body for Elasticsearch
* https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html
@ -30,7 +23,8 @@ export enum ElasticRequestState {
export function buildSearchBody(
id: string,
indexPattern: IndexPattern,
useNewFieldsApi: boolean
useNewFieldsApi: boolean,
requestAllFields?: boolean
): RequestBody | undefined {
const computedFields = indexPattern.getComputedFields();
const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields;
@ -52,6 +46,9 @@ export function buildSearchBody(
// @ts-expect-error
request.body.fields = [{ field: '*', include_unmapped: 'true' }];
request.body.runtime_mappings = runtimeFields ? runtimeFields : {};
if (requestAllFields) {
request.body._source = true;
}
} else {
request.body._source = true;
}
@ -67,47 +64,50 @@ export function useEsDocSearch({
index,
indexPatternId,
indexPatternService,
}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null] {
requestSource,
}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] {
const [indexPattern, setIndexPattern] = useState<IndexPattern | null>(null);
const [status, setStatus] = useState(ElasticRequestState.Loading);
const [hit, setHit] = useState<ElasticSearchHit | null>(null);
const { data, uiSettings } = useMemo(() => getServices(), []);
const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]);
useEffect(() => {
async function requestData() {
try {
const indexPatternEntity = await indexPatternService.get(indexPatternId);
setIndexPattern(indexPatternEntity);
const requestData = useCallback(async () => {
try {
const indexPatternEntity = await indexPatternService.get(indexPatternId);
setIndexPattern(indexPatternEntity);
const { rawResponse } = await data.search
.search({
params: {
index,
body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi)?.body,
},
})
.toPromise();
const { rawResponse } = await data.search
.search({
params: {
index,
body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body,
},
})
.toPromise();
const hits = rawResponse.hits;
const hits = rawResponse.hits;
if (hits?.hits?.[0]) {
setStatus(ElasticRequestState.Found);
setHit(hits.hits[0]);
} else {
setStatus(ElasticRequestState.NotFound);
}
} catch (err) {
if (err.savedObjectId) {
setStatus(ElasticRequestState.NotFoundIndexPattern);
} else if (err.status === 404) {
setStatus(ElasticRequestState.NotFound);
} else {
setStatus(ElasticRequestState.Error);
}
if (hits?.hits?.[0]) {
setStatus(ElasticRequestState.Found);
setHit(hits.hits[0]);
} else {
setStatus(ElasticRequestState.NotFound);
}
} catch (err) {
if (err.savedObjectId) {
setStatus(ElasticRequestState.NotFoundIndexPattern);
} else if (err.status === 404) {
setStatus(ElasticRequestState.NotFound);
} else {
setStatus(ElasticRequestState.Error);
}
}
}, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]);
useEffect(() => {
requestData();
}, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi]);
return [status, hit, indexPattern];
}, [requestData]);
return [status, hit, indexPattern, requestData];
}

View file

@ -1,21 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`returns the \`JsonCodeEditor\` component 1`] = `
<EuiFlexGroup
className="dscJsonCodeEditor"
direction="column"
gutterSize="s"
>
<EuiFlexItem>
<EuiSpacer
size="s"
/>
<div
className="eui-textRight"
>
<EuiCopy
afterMessage="Copied"
textToCopy="{
<JsonCodeEditorCommon
jsonValue="{
\\"_index\\": \\"test\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"foo\\",
@ -24,45 +11,6 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = `
\\"test\\": 123
}
}"
>
<Component />
</EuiCopy>
</div>
</EuiFlexItem>
<EuiFlexItem>
<CodeEditor
aria-label="Read only JSON view of an elasticsearch document"
editorDidMount={[Function]}
languageId="xjson"
onChange={[Function]}
options={
Object {
"automaticLayout": true,
"fontSize": 12,
"lineNumbers": "off",
"minimap": Object {
"enabled": false,
},
"overviewRulerBorder": false,
"readOnly": true,
"scrollBeyondLastLine": false,
"scrollbar": Object {
"alwaysConsumeMouseWheel": false,
},
"wordWrap": "on",
"wrappingIndent": "indent",
}
}
value="{
\\"_index\\": \\"test\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"foo\\",
\\"_score\\": 1,
\\"_source\\": {
\\"test\\": 123
}
}"
/>
</EuiFlexItem>
</EuiFlexGroup>
onEditorDidMount={[Function]}
/>
`;

View file

@ -1,3 +1,3 @@
.dscJsonCodeEditor {
width: 100%
width: 100%;
}

View file

@ -9,17 +9,8 @@
import './json_code_editor.scss';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { monaco, XJsonLang } from '@kbn/monaco';
import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CodeEditor } from '../../../../../kibana_react/public';
const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', {
defaultMessage: 'Read only JSON view of an elasticsearch document',
});
const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', {
defaultMessage: 'Copy to clipboard',
});
import { monaco } from '@kbn/monaco';
import { JsonCodeEditorCommon } from './json_code_editor_common';
interface JsonCodeEditorProps {
json: Record<string, unknown>;
@ -47,45 +38,11 @@ export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorPr
}, []);
return (
<EuiFlexGroup className="dscJsonCodeEditor" direction="column" gutterSize="s">
<EuiFlexItem>
<EuiSpacer size="s" />
<div className="eui-textRight">
<EuiCopy textToCopy={jsonValue}>
{(copy) => (
<EuiButtonEmpty size="xs" flush="right" iconType="copyClipboard" onClick={copy}>
{copyToClipboardLabel}
</EuiButtonEmpty>
)}
</EuiCopy>
</div>
</EuiFlexItem>
<EuiFlexItem>
<CodeEditor
languageId={XJsonLang.ID}
width={width}
value={jsonValue}
onChange={() => {}}
editorDidMount={setEditorCalculatedHeight}
aria-label={codeEditorAriaLabel}
options={{
automaticLayout: true,
fontSize: 12,
lineNumbers: hasLineNumbers ? 'on' : 'off',
minimap: {
enabled: false,
},
overviewRulerBorder: false,
readOnly: true,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<JsonCodeEditorCommon
jsonValue={jsonValue}
width={width}
hasLineNumbers={hasLineNumbers}
onEditorDidMount={setEditorCalculatedHeight}
/>
);
};

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import './json_code_editor.scss';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { monaco, XJsonLang } from '@kbn/monaco';
import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CodeEditor } from '../../../../../kibana_react/public';
const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', {
defaultMessage: 'Read only JSON view of an elasticsearch document',
});
const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', {
defaultMessage: 'Copy to clipboard',
});
interface JsonCodeEditorCommonProps {
jsonValue: string;
onEditorDidMount: (editor: monaco.editor.IStandaloneCodeEditor) => void;
width?: string | number;
hasLineNumbers?: boolean;
}
export const JsonCodeEditorCommon = ({
jsonValue,
width,
hasLineNumbers,
onEditorDidMount,
}: JsonCodeEditorCommonProps) => {
if (jsonValue === '') {
return null;
}
return (
<EuiFlexGroup className="dscJsonCodeEditor" direction="column" gutterSize="s">
<EuiFlexItem>
<EuiSpacer size="s" />
<div className="eui-textRight">
<EuiCopy textToCopy={jsonValue}>
{(copy) => (
<EuiButtonEmpty size="xs" flush="right" iconType="copyClipboard" onClick={copy}>
{copyToClipboardLabel}
</EuiButtonEmpty>
)}
</EuiCopy>
</div>
</EuiFlexItem>
<EuiFlexItem>
<CodeEditor
languageId={XJsonLang.ID}
width={width}
value={jsonValue || ''}
onChange={() => {}}
editorDidMount={onEditorDidMount}
aria-label={codeEditorAriaLabel}
options={{
automaticLayout: true,
fontSize: 12,
lineNumbers: hasLineNumbers ? 'on' : 'off',
minimap: {
enabled: false,
},
overviewRulerBorder: false,
readOnly: true,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const JSONCodeEditorCommonMemoized = React.memo((props: JsonCodeEditorCommonProps) => {
return <JsonCodeEditorCommon {...props} />;
});

View file

@ -0,0 +1,760 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Source Viewer component renders error state 1`] = `
<SourceViewer
hasLineNumbers={true}
id="1"
index="index1"
indexPatternId="xyz"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
width={123}
>
<EuiEmptyPrompt
body={
<div>
Could not fetch data at this time. Refresh the tab to try again.
<EuiSpacer
size="s"
/>
<EuiButton
iconType="refresh"
onClick={[Function]}
>
Refresh
</EuiButton>
</div>
}
iconType="alert"
title={
<h2>
An Error Occurred
</h2>
}
>
<div
className="euiEmptyPrompt"
>
<EuiIcon
color="subdued"
size="xxl"
type="alert"
>
<span
color="subdued"
data-euiicon-type="alert"
size="xxl"
/>
</EuiIcon>
<EuiSpacer
size="s"
>
<div
className="euiSpacer euiSpacer--s"
/>
</EuiSpacer>
<EuiTextColor
color="subdued"
>
<span
className="euiTextColor euiTextColor--subdued"
>
<EuiTitle
size="m"
>
<h2
className="euiTitle euiTitle--medium"
>
An Error Occurred
</h2>
</EuiTitle>
<EuiSpacer
size="m"
>
<div
className="euiSpacer euiSpacer--m"
/>
</EuiSpacer>
<EuiText>
<div
className="euiText euiText--medium"
>
<div>
Could not fetch data at this time. Refresh the tab to try again.
<EuiSpacer
size="s"
>
<div
className="euiSpacer euiSpacer--s"
/>
</EuiSpacer>
<EuiButton
iconType="refresh"
onClick={[Function]}
>
<EuiButtonDisplay
baseClassName="euiButton"
disabled={false}
element="button"
iconType="refresh"
isDisabled={false}
onClick={[Function]}
type="button"
>
<button
className="euiButton euiButton--primary"
disabled={false}
onClick={[Function]}
style={
Object {
"minWidth": undefined,
}
}
type="button"
>
<EuiButtonContent
className="euiButton__content"
iconSide="left"
iconType="refresh"
textProps={
Object {
"className": "euiButton__text",
}
}
>
<span
className="euiButtonContent euiButton__content"
>
<EuiIcon
className="euiButtonContent__icon"
color="inherit"
size="m"
type="refresh"
>
<span
className="euiButtonContent__icon"
color="inherit"
data-euiicon-type="refresh"
size="m"
/>
</EuiIcon>
<span
className="euiButton__text"
>
Refresh
</span>
</span>
</EuiButtonContent>
</button>
</EuiButtonDisplay>
</EuiButton>
</div>
</div>
</EuiText>
</span>
</EuiTextColor>
</div>
</EuiEmptyPrompt>
</SourceViewer>
`;
exports[`Source Viewer component renders json code editor 1`] = `
<SourceViewer
hasLineNumbers={true}
id="1"
index="index1"
indexPatternId="xyz"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
width={123}
>
<Memo()
hasLineNumbers={true}
jsonValue="{
\\"_index\\": \\"logstash-2014.09.09\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"id123\\",
\\"_score\\": 1,
\\"_source\\": {
\\"message\\": \\"Lorem ipsum dolor sit amet\\",
\\"extension\\": \\"html\\",
\\"not_mapped\\": \\"yes\\",
\\"bytes\\": 100,
\\"objectArray\\": [
{
\\"foo\\": true
}
],
\\"relatedContent\\": {
\\"test\\": 1
},
\\"scripted\\": 123,
\\"_underscore\\": 123
}
}"
onEditorDidMount={[Function]}
width={123}
>
<JsonCodeEditorCommon
hasLineNumbers={true}
jsonValue="{
\\"_index\\": \\"logstash-2014.09.09\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"id123\\",
\\"_score\\": 1,
\\"_source\\": {
\\"message\\": \\"Lorem ipsum dolor sit amet\\",
\\"extension\\": \\"html\\",
\\"not_mapped\\": \\"yes\\",
\\"bytes\\": 100,
\\"objectArray\\": [
{
\\"foo\\": true
}
],
\\"relatedContent\\": {
\\"test\\": 1
},
\\"scripted\\": 123,
\\"_underscore\\": 123
}
}"
onEditorDidMount={[Function]}
width={123}
>
<EuiFlexGroup
className="dscJsonCodeEditor"
direction="column"
gutterSize="s"
>
<div
className="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--directionColumn euiFlexGroup--responsive dscJsonCodeEditor"
>
<EuiFlexItem>
<div
className="euiFlexItem"
>
<EuiSpacer
size="s"
>
<div
className="euiSpacer euiSpacer--s"
/>
</EuiSpacer>
<div
className="eui-textRight"
>
<EuiCopy
afterMessage="Copied"
textToCopy="{
\\"_index\\": \\"logstash-2014.09.09\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"id123\\",
\\"_score\\": 1,
\\"_source\\": {
\\"message\\": \\"Lorem ipsum dolor sit amet\\",
\\"extension\\": \\"html\\",
\\"not_mapped\\": \\"yes\\",
\\"bytes\\": 100,
\\"objectArray\\": [
{
\\"foo\\": true
}
],
\\"relatedContent\\": {
\\"test\\": 1
},
\\"scripted\\": 123,
\\"_underscore\\": 123
}
}"
>
<EuiToolTip
delay="regular"
onMouseOut={[Function]}
position="top"
>
<span
className="euiToolTipAnchor"
onKeyUp={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<EuiButtonEmpty
flush="right"
iconType="copyClipboard"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
size="xs"
>
<button
className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--flushRight"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<EuiButtonContent
className="euiButtonEmpty__content"
iconSide="left"
iconSize="s"
iconType="copyClipboard"
textProps={
Object {
"className": "euiButtonEmpty__text",
}
}
>
<span
className="euiButtonContent euiButtonEmpty__content"
>
<EuiIcon
className="euiButtonContent__icon"
color="inherit"
size="s"
type="copyClipboard"
>
<span
className="euiButtonContent__icon"
color="inherit"
data-euiicon-type="copyClipboard"
size="s"
/>
</EuiIcon>
<span
className="euiButtonEmpty__text"
>
Copy to clipboard
</span>
</span>
</EuiButtonContent>
</button>
</EuiButtonEmpty>
</span>
</EuiToolTip>
</EuiCopy>
</div>
</div>
</EuiFlexItem>
<EuiFlexItem>
<div
className="euiFlexItem"
>
<CodeEditor
aria-label="Read only JSON view of an elasticsearch document"
editorDidMount={[Function]}
languageId="xjson"
onChange={[Function]}
options={
Object {
"automaticLayout": true,
"fontSize": 12,
"lineNumbers": "on",
"minimap": Object {
"enabled": false,
},
"overviewRulerBorder": false,
"readOnly": true,
"scrollBeyondLastLine": false,
"scrollbar": Object {
"alwaysConsumeMouseWheel": false,
},
"wordWrap": "on",
"wrappingIndent": "indent",
}
}
value="{
\\"_index\\": \\"logstash-2014.09.09\\",
\\"_type\\": \\"doc\\",
\\"_id\\": \\"id123\\",
\\"_score\\": 1,
\\"_source\\": {
\\"message\\": \\"Lorem ipsum dolor sit amet\\",
\\"extension\\": \\"html\\",
\\"not_mapped\\": \\"yes\\",
\\"bytes\\": 100,
\\"objectArray\\": [
{
\\"foo\\": true
}
],
\\"relatedContent\\": {
\\"test\\": 1
},
\\"scripted\\": 123,
\\"_underscore\\": 123
}
}"
width={123}
>
<EuiErrorBoundary>
<Suspense
fallback={<Fallback />}
>
<Fallback>
<EuiDelayRender
delay={500}
/>
</Fallback>
</Suspense>
</EuiErrorBoundary>
</CodeEditor>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</JsonCodeEditorCommon>
</Memo()>
</SourceViewer>
`;
exports[`Source Viewer component renders loading state 1`] = `
<SourceViewer
hasLineNumbers={true}
id="1"
index="index1"
indexPatternId="xyz"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
width={123}
>
<div
className="sourceViewer__loading"
>
<EuiLoadingSpinner
className="sourceViewer__loadingSpinner"
>
<span
className="euiLoadingSpinner euiLoadingSpinner--medium sourceViewer__loadingSpinner"
/>
</EuiLoadingSpinner>
<EuiText
color="subdued"
size="xs"
>
<div
className="euiText euiText--extraSmall"
>
<EuiTextColor
color="subdued"
component="div"
>
<div
className="euiTextColor euiTextColor--subdued"
>
<FormattedMessage
defaultMessage="Loading JSON"
id="discover.loadingJSON"
values={Object {}}
>
Loading JSON
</FormattedMessage>
</div>
</EuiTextColor>
</div>
</EuiText>
</div>
</SourceViewer>
`;

View file

@ -0,0 +1,14 @@
.sourceViewer__loading {
display: flex;
flex-direction: row;
justify-content: left;
flex: 1 0 100%;
text-align: center;
height: 100%;
width: 100%;
margin-top: $euiSizeS;
}
.sourceViewer__loadingSpinner {
margin-right: $euiSizeS;
}

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { SourceViewer } from './source_viewer';
import * as hooks from '../doc/use_es_doc_search';
import * as useUiSettingHook from 'src/plugins/kibana_react/public/ui_settings/use_ui_setting';
import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
import { JsonCodeEditorCommon } from '../json_code_editor/json_code_editor_common';
jest.mock('../../../kibana_services', () => ({
getServices: jest.fn(),
}));
import { getServices, IndexPattern } from '../../../kibana_services';
const mockIndexPattern = {
getComputedFields: () => [],
} as never;
const getMock = jest.fn(() => Promise.resolve(mockIndexPattern));
const mockIndexPatternService = ({
get: getMock,
} as unknown) as IndexPattern;
(getServices as jest.Mock).mockImplementation(() => ({
uiSettings: {
get: (key: string) => {
if (key === 'discover:useNewFieldsApi') {
return true;
}
},
},
data: {
indexPatternService: mockIndexPatternService,
},
}));
describe('Source Viewer component', () => {
test('renders loading state', () => {
jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]);
const comp = mountWithIntl(
<SourceViewer
id={'1'}
index={'index1'}
indexPatternId={'xyz'}
width={123}
hasLineNumbers={true}
/>
);
expect(comp).toMatchSnapshot();
const loadingIndicator = comp.find(EuiLoadingSpinner);
expect(loadingIndicator).not.toBe(null);
});
test('renders error state', () => {
jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]);
const comp = mountWithIntl(
<SourceViewer
id={'1'}
index={'index1'}
indexPatternId={'xyz'}
width={123}
hasLineNumbers={true}
/>
);
expect(comp).toMatchSnapshot();
const errorPrompt = comp.find(EuiEmptyPrompt);
expect(errorPrompt.length).toBe(1);
const refreshButton = comp.find(EuiButton);
expect(refreshButton.length).toBe(1);
});
test('renders json code editor', () => {
const mockHit = {
_index: 'logstash-2014.09.09',
_type: 'doc',
_id: 'id123',
_score: 1,
_source: {
message: 'Lorem ipsum dolor sit amet',
extension: 'html',
not_mapped: 'yes',
bytes: 100,
objectArray: [{ foo: true }],
relatedContent: {
test: 1,
},
scripted: 123,
_underscore: 123,
},
} as never;
jest
.spyOn(hooks, 'useEsDocSearch')
.mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]);
jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => {
return false;
});
const comp = mountWithIntl(
<SourceViewer
id={'1'}
index={'index1'}
indexPatternId={'xyz'}
width={123}
hasLineNumbers={true}
/>
);
expect(comp).toMatchSnapshot();
const jsonCodeEditor = comp.find(JsonCodeEditorCommon);
expect(jsonCodeEditor).not.toBe(null);
});
});

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import './source_viewer.scss';
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { monaco } from '@kbn/monaco';
import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useEsDocSearch } from '../doc/use_es_doc_search';
import { JSONCodeEditorCommonMemoized } from '../json_code_editor/json_code_editor_common';
import { ElasticRequestState } from '../doc/elastic_request_state';
import { getServices } from '../../../../public/kibana_services';
import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common';
interface SourceViewerProps {
id: string;
index: string;
indexPatternId: string;
hasLineNumbers: boolean;
width?: number;
}
export const SourceViewer = ({
id,
index,
indexPatternId,
width,
hasLineNumbers,
}: SourceViewerProps) => {
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor>();
const [jsonValue, setJsonValue] = useState<string>('');
const indexPatternService = getServices().data.indexPatterns;
const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE);
const [reqState, hit, , requestData] = useEsDocSearch({
id,
index,
indexPatternId,
indexPatternService,
requestSource: useNewFieldsApi,
});
useEffect(() => {
if (reqState === ElasticRequestState.Found && hit) {
setJsonValue(JSON.stringify(hit, undefined, 2));
}
}, [reqState, hit]);
// setting editor height based on lines height and count to stretch and fit its content
useEffect(() => {
if (!editor) {
return;
}
const editorElement = editor.getDomNode();
if (!editorElement) {
return;
}
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight);
const lineCount = editor.getModel()?.getLineCount() || 1;
const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight;
if (!jsonValue || jsonValue === '') {
editorElement.style.height = '0px';
} else {
editorElement.style.height = `${height}px`;
}
editor.layout();
}, [editor, jsonValue]);
const loadingState = (
<div className="sourceViewer__loading">
<EuiLoadingSpinner className="sourceViewer__loadingSpinner" />
<EuiText size="xs" color="subdued">
<FormattedMessage id="discover.loadingJSON" defaultMessage="Loading JSON" />
</EuiText>
</div>
);
const errorMessageTitle = (
<h2>
{i18n.translate('discover.sourceViewer.errorMessageTitle', {
defaultMessage: 'An Error Occurred',
})}
</h2>
);
const errorMessage = (
<div>
{i18n.translate('discover.sourceViewer.errorMessage', {
defaultMessage: 'Could not fetch data at this time. Refresh the tab to try again.',
})}
<EuiSpacer size="s" />
<EuiButton iconType="refresh" onClick={requestData}>
{i18n.translate('discover.sourceViewer.refresh', {
defaultMessage: 'Refresh',
})}
</EuiButton>
</div>
);
const errorState = (
<EuiEmptyPrompt iconType="alert" title={errorMessageTitle} body={errorMessage} />
);
if (
reqState === ElasticRequestState.Error ||
reqState === ElasticRequestState.NotFound ||
reqState === ElasticRequestState.NotFoundIndexPattern
) {
return errorState;
}
if (reqState === ElasticRequestState.Loading || jsonValue === '') {
return loadingState;
}
return (
<JSONCodeEditorCommonMemoized
jsonValue={jsonValue}
width={width}
hasLineNumbers={hasLineNumbers}
onEditorDidMount={(editorNode: monaco.editor.IStandaloneCodeEditor) => setEditor(editorNode)}
/>
);
};

View file

@ -37,7 +37,7 @@ import { UrlGeneratorState } from '../../share/public';
import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types';
import { DocViewsRegistry } from './application/doc_views/doc_views_registry';
import { DocViewTable } from './application/components/table/table';
import { JsonCodeEditor } from './application/components/json_code_editor/json_code_editor';
import {
setDocViewsRegistry,
setUrlTracker,
@ -63,6 +63,7 @@ import { SearchEmbeddableFactory } from './application/embeddable';
import { UsageCollectionSetup } from '../../usage_collection/public';
import { replaceUrlHashQuery } from '../../kibana_utils/public/';
import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public';
import { SourceViewer } from './application/components/source_viewer/source_viewer';
declare module '../../share/public' {
export interface UrlGeneratorStateMapping {
@ -178,7 +179,6 @@ export class DiscoverPlugin
})
);
}
this.docViewsRegistry = new DocViewsRegistry();
setDocViewsRegistry(this.docViewsRegistry);
this.docViewsRegistry.addDocView({
@ -193,8 +193,14 @@ export class DiscoverPlugin
defaultMessage: 'JSON',
}),
order: 20,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ({ hit }) => <JsonCodeEditor json={hit as any} hasLineNumbers />,
component: ({ hit, indexPattern }) => (
<SourceViewer
index={hit._index}
id={hit._id}
indexPatternId={indexPattern?.id || ''}
hasLineNumbers
/>
),
});
const {
@ -273,6 +279,7 @@ export class DiscoverPlugin
// make sure the index pattern list is up to date
await dataStart.indexPatterns.clearCache();
const { renderApp } = await import('./application/application');
params.element.classList.add('dscAppWrapper');
const unmount = await renderApp(innerAngularName, params.element);

View file

@ -11,6 +11,7 @@ import { FtrProviderContext } from './ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const log = getService('log');
const docTable = getService('docTable');
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
@ -58,5 +59,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.discover.getDocHeader()).not.to.have.string('_score');
expect(await PageObjects.discover.getDocHeader()).to.have.string('Document');
});
it('displays _source viewer in doc viewer', async function () {
await docTable.clickRowToggle({ rowIndex: 0 });
await PageObjects.discover.isShowingDocViewer();
await PageObjects.discover.clickDocViewerTab(1);
await PageObjects.discover.expectSourceViewerToExist();
});
});
}

View file

@ -296,6 +296,14 @@ export class DiscoverPageObject extends FtrService {
return await this.testSubjects.exists('kbnDocViewer');
}
public async clickDocViewerTab(index: number) {
return await this.find.clickByCssSelector(`#kbn_doc_viewer_tab_${index}`);
}
public async expectSourceViewerToExist() {
return await this.find.byClassName('monaco-editor');
}
public async getMarks() {
const table = await this.docTable.getTable();
const marks = await table.findAllByTagName('mark');