[App Search] Add a Result Component (#85046)

This commit is contained in:
Jason Stoltzfus 2020-12-11 14:00:38 -05:00 committed by GitHub
parent a4caffae2f
commit 31a1cc0541
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 923 additions and 48 deletions

View file

@ -14,7 +14,7 @@ import { EuiPageContent, EuiBasicTable } from '@elastic/eui';
import { Loading } from '../../../shared/loading';
import { DocumentDetail } from '.';
import { ResultFieldValue } from '../result_field_value';
import { ResultFieldValue } from '../result';
describe('DocumentDetail', () => {
const values = {

View file

@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n';
import { Loading } from '../../../shared/loading';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { FlashMessages } from '../../../shared/flash_messages';
import { ResultFieldValue } from '../result_field_value';
import { ResultFieldValue } from '../result';
import { DocumentDetailLogic } from './document_detail_logic';
import { FieldDetails } from './types';

View file

@ -28,6 +28,7 @@ jest.mock('../../../shared/flash_messages', () => ({
import { setQueuedSuccessMessage, flashAPIErrors } from '../../../shared/flash_messages';
import { DocumentDetailLogic } from './document_detail_logic';
import { InternalSchemaTypes } from '../../../shared/types';
describe('DocumentDetailLogic', () => {
const DEFAULT_VALUES = {
@ -61,7 +62,7 @@ describe('DocumentDetailLogic', () => {
describe('actions', () => {
describe('setFields', () => {
it('should set fields to the provided value and dataLoading to false', () => {
const fields = [{ name: 'foo', value: ['foo'], type: 'string' }];
const fields = [{ name: 'foo', value: ['foo'], type: 'string' as InternalSchemaTypes }];
mount({
dataLoading: true,

View file

@ -2,6 +2,10 @@
.sui-results-container {
flex-grow: 1;
padding: 0;
> li + li {
margin-top: $euiSize;
}
}
.documentsSearchExperience__sidebar {

View file

@ -43,6 +43,15 @@ describe('SearchExperienceContent', () => {
it('passes engineName to the result view', () => {
const props = {
result: {
id: {
raw: '1',
},
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
foo: {
raw: 'bar',
},
@ -51,7 +60,7 @@ describe('SearchExperienceContent', () => {
const wrapper = shallow(<SearchExperienceContent />);
const resultView: any = wrapper.find(Results).prop('resultView');
expect(resultView(props)).toEqual(<ResultView engineName="engine1" {...props} />);
expect(resultView(props)).toEqual(<ResultView {...props} />);
});
it('renders pagination', () => {

View file

@ -14,24 +14,18 @@ import { useValues } from 'kea';
import { ResultView } from './views';
import { Pagination } from './pagination';
import { Props as ResultViewProps } from './views/result_view';
import { useSearchContextState } from './hooks';
import { DocumentCreationButton } from '../document_creation_button';
import { AppLogic } from '../../../app_logic';
import { EngineLogic } from '../../engine';
import { DOCS_PREFIX } from '../../../routes';
// TODO This is temporary until we create real Result type
interface Result {
[key: string]: {
raw: string | string[] | number | number[] | undefined;
};
}
export const SearchExperienceContent: React.FC = () => {
const { resultSearchTerm, totalResults, wasSearched } = useSearchContextState();
const { myRole } = useValues(AppLogic);
const { engineName, isMetaEngine } = useValues(EngineLogic);
const { isMetaEngine } = useValues(EngineLogic);
if (!wasSearched) return null;
@ -49,8 +43,8 @@ export const SearchExperienceContent: React.FC = () => {
<EuiSpacer />
<Results
titleField="id"
resultView={(props: { result: Result }) => {
return <ResultView {...props} engineName={engineName} />;
resultView={(props: ResultViewProps) => {
return <ResultView {...props} />;
}}
/>
<EuiSpacer />

View file

@ -9,16 +9,26 @@ import React from 'react';
import { shallow } from 'enzyme';
import { ResultView } from '.';
import { Result } from '../../../result/result';
describe('ResultView', () => {
const result = {
id: {
raw: '1',
},
title: {
raw: 'A title',
},
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
};
it('renders', () => {
const wrapper = shallow(<ResultView result={result} engineName="engine1" />);
expect(wrapper.find('div').length).toBe(1);
const wrapper = shallow(<ResultView result={result} />);
expect(wrapper.find(Result).exists()).toBe(true);
});
});

View file

@ -5,38 +5,18 @@
*/
import React from 'react';
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { Result as ResultType } from '../../../result/types';
import { Result } from '../../../result/result';
// TODO replace this with a real result type when we implement a more sophisticated
// ResultView
interface Result {
[key: string]: {
raw: string | string[] | number | number[] | undefined;
};
export interface Props {
result: ResultType;
}
interface Props {
engineName: string;
result: Result;
}
export const ResultView: React.FC<Props> = ({ engineName, result }) => {
// TODO Replace this entire component when we migrate StuiResult
export const ResultView: React.FC<Props> = ({ result }) => {
return (
<li>
<EuiPanel>
<EuiLinkTo to={`/engines/${engineName}/documents/${result.id.raw}`}>
<strong>{result.id.raw}</strong>
</EuiLinkTo>
{Object.entries(result).map(([key, value]) => (
<div key={key} style={{ wordBreak: 'break-all' }}>
{key}: {value.raw}
</div>
))}
</EuiPanel>
<EuiSpacer />
<Result result={result} />
</li>
);
};

View file

@ -4,8 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { InternalSchemaTypes } from '../../../shared/types';
export interface FieldDetails {
name: string;
value: string | string[];
type: string;
type: InternalSchemaTypes;
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { Library } from './library';

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiSpacer,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiPageContentBody,
EuiPageContent,
} from '@elastic/eui';
import React from 'react';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { Result } from '../result/result';
export const Library: React.FC = () => {
const props = {
result: {
id: {
raw: '1',
},
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
title: {
raw: 'A title',
},
description: {
raw: 'A description',
},
states: {
raw: ['Pennsylvania', 'Ohio'],
},
visitors: {
raw: 1000,
},
size: {
raw: 200,
},
length: {
raw: 100,
},
},
};
return (
<>
<SetPageChrome trail={['Library']} />
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Library</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<EuiTitle size="m">
<h2>Result</h2>
</EuiTitle>
<EuiSpacer />
<EuiTitle size="s">
<h3>5 or more fields</h3>
</EuiTitle>
<EuiSpacer />
<Result {...props} />
<EuiSpacer />
<EuiTitle size="s">
<h3>5 or less fields</h3>
</EuiTitle>
<EuiSpacer />
<Result
{...{
result: {
id: props.result.id,
_meta: props.result._meta,
title: props.result.title,
description: props.result.description,
},
}}
/>
<EuiSpacer />
<EuiTitle size="s">
<h3>With just an id</h3>
</EuiTitle>
<EuiSpacer />
<Result
{...{
result: {
...props.result,
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
},
}}
/>
<EuiSpacer />
<EuiTitle size="s">
<h3>With an id and a score</h3>
</EuiTitle>
<EuiSpacer />
<Result
{...{
showScore: true,
result: {
...props.result,
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
},
}}
/>
<EuiSpacer />
<EuiTitle size="s">
<h3>With an id and a score and an engine</h3>
</EuiTitle>
<EuiSpacer />
<Result
{...{
showScore: true,
result: {
...props.result,
_meta: {
id: '1',
scopedId: '2',
score: 100,
engine: 'my-engine',
},
},
}}
/>
<EuiSpacer />
<EuiTitle size="s">
<h3>With a long id, a long engine name, a long field key, and a long value</h3>
</EuiTitle>
<EuiSpacer />
<Result
{...{
result: {
...props.result,
'this-description-is-a-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-long-key': {
raw:
'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?',
},
_meta: {
id: 'my-id-is-a-really-long-id-yes-it-is',
scopedId: '2',
score: 100,
engine: 'my-engine-is-a-really-long-engin-name-yes-it-is',
},
},
}}
/>
<EuiSpacer />
</EuiPageContentBody>
</EuiPageContent>
</>
);
};

View file

@ -0,0 +1,30 @@
.appSearchResult {
display: flex;
&__content {
width: 100%;
padding: $euiSize;
overflow: hidden;
}
&__hiddenFieldsIndicator {
font-size: $euiFontSizeXS;
color: $euiColorDarkShade;
margin-top: $euiSizeS;
}
&__actionButton {
display: flex;
justify-content: center;
align-items: center;
width: $euiSizeL * 2;
border-left: $euiBorderThin;
&:hover,
&:focus,
&:active {
background-color: $euiPageBackgroundColor;
cursor: pointer;
}
}
}

View file

@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiPanel } from '@elastic/eui';
import { ResultField } from './result_field';
import { ResultHeader } from './result_header';
import { Result } from './result';
describe('Result', () => {
const props = {
result: {
id: {
raw: '1',
},
title: {
raw: 'A title',
},
description: {
raw: 'A description',
},
length: {
raw: 100,
},
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
},
};
it('renders', () => {
const wrapper = shallow(<Result {...props} />);
expect(wrapper.find(EuiPanel).exists()).toBe(true);
});
it('should render a ResultField for each field except id and _meta', () => {
const wrapper = shallow(<Result {...props} />);
expect(wrapper.find(ResultField).map((rf) => rf.prop('field'))).toEqual([
'title',
'description',
'length',
]);
});
it('passes through showScore and resultMeta to ResultHeader', () => {
const wrapper = shallow(<Result {...props} showScore={true} />);
expect(wrapper.find(ResultHeader).prop('showScore')).toBe(true);
expect(wrapper.find(ResultHeader).prop('resultMeta')).toEqual({
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
});
});
describe('when there are more than 5 fields', () => {
const propsWithMoreFields = {
result: {
id: {
raw: '1',
},
title: {
raw: 'A title',
},
description: {
raw: 'A description',
},
length: {
raw: 100,
},
states: {
raw: ['Pennsylvania', 'Ohio'],
},
visitors: {
raw: 1000,
},
size: {
raw: 200,
},
_meta: {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
},
},
};
describe('the initial render', () => {
let wrapper: ShallowWrapper;
beforeAll(() => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
});
it('renders a collapse button', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false);
});
it('does not render an expand button', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true);
});
it('renders a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual(
'1 more fields'
);
});
it('shows no more than 5 fields', () => {
expect(wrapper.find(ResultField).length).toEqual(5);
});
});
describe('after clicking the expand button', () => {
let wrapper: ShallowWrapper;
beforeAll(() => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true);
wrapper.find('.appSearchResult__actionButton').simulate('click');
});
it('renders a collapse button', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(true);
});
it('does not render an expand button', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(false);
});
it('does not render a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').exists()).toBe(false);
});
it('shows all fields', () => {
expect(wrapper.find(ResultField).length).toEqual(6);
});
});
describe('after clicking the collapse button', () => {
let wrapper: ShallowWrapper;
beforeAll(() => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true);
wrapper.find('.appSearchResult__actionButton').simulate('click');
wrapper.find('.appSearchResult__actionButton').simulate('click');
});
it('renders a collapse button', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false);
});
it('does not render an expand button', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true);
});
it('renders a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual(
'1 more fields'
);
});
it('shows no more than 5 fields', () => {
expect(wrapper.find(ResultField).length).toEqual(5);
});
});
});
});

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useMemo } from 'react';
import './result.scss';
import { EuiPanel, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldValue, Result as ResultType } from './types';
import { ResultField } from './result_field';
import { ResultHeader } from './result_header';
interface Props {
result: ResultType;
showScore?: boolean;
}
const RESULT_CUTOFF = 5;
export const Result: React.FC<Props> = ({ result, showScore }) => {
const [isOpen, setIsOpen] = useState(false);
const ID = 'id';
const META = '_meta';
const resultMeta = result[META];
const resultFields = useMemo(
() => Object.entries(result).filter(([key]) => key !== META && key !== ID),
[result]
);
const numResults = resultFields.length;
return (
<EuiPanel
paddingSize="none"
className="appSearchResult"
data-test-subj="AppSearchResult"
title={i18n.translate('xpack.enterpriseSearch.appSearch.result.title', {
defaultMessage: 'View document details',
})}
>
<article className="appSearchResult__content">
<ResultHeader resultMeta={resultMeta} showScore={!!showScore} />
<div className="appSearchResult__body">
{resultFields
.slice(0, isOpen ? resultFields.length : RESULT_CUTOFF)
.map(([field, value]: [string, FieldValue]) => (
<ResultField key={field} field={field} raw={value.raw} snippet={value.snippet} />
))}
</div>
{numResults > RESULT_CUTOFF && !isOpen && (
<footer className="appSearchResult__hiddenFieldsIndicator">
{i18n.translate('xpack.enterpriseSearch.appSearch.result.numberOfAdditionalFields', {
defaultMessage: '{numberOfAdditionalFields} more fields',
values: {
numberOfAdditionalFields: numResults - RESULT_CUTOFF,
},
})}
</footer>
)}
</article>
{numResults > RESULT_CUTOFF && (
<button
type="button"
className="appSearchResult__actionButton"
onClick={() => setIsOpen(!isOpen)}
aria-label={
isOpen
? i18n.translate('xpack.enterpriseSearch.appSearch.result.hideAdditionalFields', {
defaultMessage: 'Hide additional fields',
})
: i18n.translate('xpack.enterpriseSearch.appSearch.result.showAdditionalFields', {
defaultMessage: 'Show additional fields',
})
}
>
{isOpen ? (
<EuiIcon data-test-subj="CollapseResult" type="arrowUp" />
) : (
<EuiIcon data-test-subj="ExpandResult" type="arrowDown" />
)}
</button>
)}
</EuiPanel>
);
};

View file

@ -0,0 +1,25 @@
.appSearchResultField {
font-size: $euiFontSizeS;
line-height: $euiLineHeight;
display: grid;
grid-template-columns: 0.85fr $euiSizeXL 1fr;
grid-gap: $euiSizeXS;
&__separator {
text-align: center;
&:after {
content: '=>';
color: $euiColorDarkShade;
}
}
&__value {
padding-left: $euiSize;
overflow: hidden;
@include euiBreakpoint('xs') {
padding-left: 0;
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { ResultField } from './result_field';
describe('ResultField', () => {
it('renders', () => {
const wrapper = shallow(
<ResultField
field="title"
raw="The Catcher in the Rye"
snippet="The <em>Catcher</em> in the Rye"
type="string"
/>
);
expect(wrapper.find('ResultFieldValue').exists()).toBe(true);
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ResultFieldValue } from '.';
import { FieldType, Raw, Snippet } from './types';
import './result_field.scss';
interface Props {
field: string;
raw?: Raw;
snippet?: Snippet;
type?: FieldType;
}
export const ResultField: React.FC<Props> = ({ field, raw, snippet, type }) => {
return (
<div className="appSearchResultField">
<div className="appSearchResultField__key eui-textTruncate">{field}</div>
<div className="appSearchResultField__separator" aria-hidden />
<div className="appSearchResultField__value">
<ResultFieldValue className="eui-textTruncate" raw={raw} snippet={snippet} type={type} />
</div>
</div>
);
};

View file

@ -11,6 +11,7 @@
font-family: $euiCodeFontFamily;
}
&--geolocation,
&--location {
color: $euiColorSuccessText;
font-family: $euiCodeFontFamily;

View file

@ -8,7 +8,7 @@ import React from 'react';
import classNames from 'classnames';
import { Raw, Snippet } from '../../types';
import { FieldType, Raw, Snippet } from './types';
import './result_field_value.scss';
@ -40,7 +40,7 @@ const isFieldValueEmpty = (type?: string, raw?: Raw, snippet?: Snippet) => {
interface Props {
raw?: Raw;
snippet?: Snippet;
type?: string;
type?: FieldType;
className?: string;
}

View file

@ -0,0 +1,28 @@
.appSearchResultHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $euiSizeS;
@include euiBreakpoint('xs') {
flex-direction: column;
}
&__column {
display: flex;
flex-wrap: wrap;
@include euiBreakpoint('xs') {
flex-direction: column;
}
& + &,
.appSearchResultHeaderItem + .appSearchResultHeaderItem {
margin-left: $euiSizeL;
@include euiBreakpoint('xs') {
margin-left: 0;
}
}
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { ResultHeader } from './result_header';
describe('ResultHeader', () => {
const resultMeta = {
id: '1',
scopedId: '1',
score: 100,
engine: 'my-engine',
};
it('renders', () => {
const wrapper = shallow(<ResultHeader showScore={false} resultMeta={resultMeta} />);
expect(wrapper.isEmptyRender()).toBe(false);
});
it('always renders an id', () => {
const wrapper = shallow(<ResultHeader showScore={false} resultMeta={resultMeta} />);
expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1');
});
describe('score', () => {
it('renders score if showScore is true ', () => {
const wrapper = shallow(<ResultHeader showScore={true} resultMeta={resultMeta} />);
expect(wrapper.find('[data-test-subj="ResultScore"]').prop('value')).toEqual(100);
});
it('does not render score if showScore is false', () => {
const wrapper = shallow(<ResultHeader showScore={false} resultMeta={resultMeta} />);
expect(wrapper.find('[data-test-subj="ResultScore"]').exists()).toBe(false);
});
});
describe('engine', () => {
it('renders engine name if the ids dont match, which means it is a meta engine', () => {
const wrapper = shallow(
<ResultHeader
showScore={true}
resultMeta={{
...resultMeta,
id: '1',
scopedId: 'my-engine|1',
}}
/>
);
expect(wrapper.find('[data-test-subj="ResultEngine"]').prop('value')).toBe('my-engine');
});
it('does not render an engine name if the ids match, which means it is not a meta engine', () => {
const wrapper = shallow(
<ResultHeader
showScore={true}
resultMeta={{
...resultMeta,
id: '1',
scopedId: '1',
}}
/>
);
expect(wrapper.find('[data-test-subj="ResultEngine"]').exists()).toBe(false);
});
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ResultHeaderItem } from './result_header_item';
import { ResultMeta } from './types';
import './result_header.scss';
interface Props {
showScore: boolean;
resultMeta: ResultMeta;
}
export const ResultHeader: React.FC<Props> = ({ showScore, resultMeta }) => {
const showEngineLabel: boolean = resultMeta.id !== resultMeta.scopedId;
return (
<header className="appSearchResultHeader">
{showScore && (
<div className="appSearchResultHeader__column">
<ResultHeaderItem
data-test-subj="ResultScore"
field="score"
value={resultMeta.score}
type="score"
/>
</div>
)}
<div className="appSearchResultHeader__column">
{showEngineLabel && (
<ResultHeaderItem
data-test-subj="ResultEngine"
field="engine"
value={resultMeta.engine}
type="string"
/>
)}
<ResultHeaderItem data-test-subj="ResultId" field="id" value={resultMeta.id} type="id" />
</div>
</header>
);
};

View file

@ -0,0 +1,16 @@
.appSearchResultHeaderItem {
display: flex;
&__key,
&__value {
line-height: $euiLineHeight;
font-size: $euiFontSizeXS;
}
&__key {
text-transform: uppercase;
font-weight: $euiFontWeightLight;
color: $euiColorDarkShade;
margin-right: $euiSizeXS;
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { ResultHeaderItem } from './result_header_item';
describe('ResultHeaderItem', () => {
it('renders', () => {
const wrapper = mount(<ResultHeaderItem field="id" value="001" type="id" />);
expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual('id');
expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('001');
});
it('will truncate long field names', () => {
const wrapper = mount(
<ResultHeaderItem
field="a-really-really-really-really-field-name"
value="001"
type="string"
/>
);
expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual(
'a-really-really-really-really-…'
);
});
it('will truncate long values', () => {
const wrapper = mount(
<ResultHeaderItem field="foo" value="a-really-really-really-really-value" type="string" />
);
expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual(
'a-really-really-really-really-…'
);
});
it('will truncate long values from the beginning if the type is "id"', () => {
const wrapper = mount(
<ResultHeaderItem field="foo" value="a-really-really-really-really-value" type="id" />
);
expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual(
'…lly-really-really-really-value'
);
});
it('will round any numeric values that are passed in to 2 decimals, regardless of the explicit "type" passed', () => {
const wrapper = mount(<ResultHeaderItem field="foo" value={5.19383718193} type="string" />);
expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('5.19');
});
it('if the value passed in is undefined, it will render "-"', () => {
const wrapper = mount(<ResultHeaderItem field="foo" type="string" />);
expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('-');
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import './result_header_item.scss';
import { TruncatedContent } from '../../../shared/truncate';
interface Props {
field: string;
value?: string | number;
type: 'id' | 'score' | 'string';
}
const MAX_CHARACTER_LENGTH = 30;
export const ResultHeaderItem: React.FC<Props> = ({ field, type, value }) => {
let formattedValue = '-';
if (typeof value === 'string') {
formattedValue = value;
} else if (typeof value === 'number') {
formattedValue = parseFloat((value as number).toFixed(2)).toString();
}
return (
<div className="appSearchResultHeaderItem">
<div className="appSearchResultHeaderItem__key">
<TruncatedContent content={field} length={MAX_CHARACTER_LENGTH} tooltipType="title" />
</div>
<div className="appSearchResultHeaderItem__value">
<TruncatedContent
content={formattedValue}
length={MAX_CHARACTER_LENGTH}
tooltipType="title"
beginning={type === 'id'}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InternalSchemaTypes, SchemaTypes } from '../../../shared/types';
export type FieldType = InternalSchemaTypes | SchemaTypes;
export type Raw = string | string[] | number | number[];
export type Snippet = string;
export interface FieldValue {
raw?: Raw;
snippet?: Snippet;
}
export interface ResultMeta {
id: string;
scopedId: string;
score?: number;
engine: string;
}
// A search result item
export type Result = {
id: {
raw: string;
};
_meta: ResultMeta;
} & {
// this should be a FieldType object, but there's no good way to do that in TS: https://github.com/microsoft/TypeScript/issues/17867
// You'll need to cast it to FieldValue whenever you use it.
[key: string]: object;
};

View file

@ -26,6 +26,7 @@ import {
ROLE_MAPPINGS_PATH,
ENGINES_PATH,
ENGINE_PATH,
LIBRARY_PATH,
} from './routes';
import { SetupGuide } from './components/setup_guide';
@ -35,6 +36,7 @@ import { EnginesOverview, ENGINES_TITLE } from './components/engines';
import { Settings, SETTINGS_TITLE } from './components/settings';
import { Credentials, CREDENTIALS_TITLE } from './components/credentials';
import { ROLE_MAPPINGS_TITLE } from './components/role_mappings';
import { Library } from './components/library';
export const AppSearch: React.FC<InitialAppData> = (props) => {
const { config } = useValues(KibanaLogic);
@ -66,6 +68,11 @@ export const AppSearchConfigured: React.FC<InitialAppData> = (props) => {
<Route exact path={SETUP_GUIDE_PATH}>
<SetupGuide />
</Route>
{process.env.NODE_ENV === 'development' && (
<Route path={LIBRARY_PATH}>
<Library />
</Route>
)}
<Route path={ENGINE_PATH}>
<Layout navigation={<AppSearchNav subNav={<EngineNav />} />} readOnlyMode={readOnlyMode}>
<EngineRouter />

View file

@ -12,6 +12,7 @@ export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT
export const ROOT_PATH = '/';
export const SETUP_GUIDE_PATH = '/setup_guide';
export const LIBRARY_PATH = '/library';
export const SETTINGS_PATH = '/settings/account';
export const CREDENTIALS_PATH = '/credentials';
export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included

View file

@ -7,5 +7,3 @@
export * from '../../../common/types/app_search';
export { Role, RoleTypes, AbilityTypes } from './utils/role';
export { Engine } from './components/engine/types';
export type Raw = string | string[] | number | number[];
export type Snippet = string;

View file

@ -7,7 +7,8 @@
import { ADD, UPDATE } from './constants/operations';
export type SchemaTypes = 'text' | 'number' | 'geolocation' | 'date';
// Certain API endpoints will use these internal type names, which map to the external names above
export type InternalSchemaTypes = 'string' | 'float' | 'location' | 'date';
export interface Schema {
[key: string]: SchemaTypes;
}