[App Search] Add a Result Component (#85046)
This commit is contained in:
parent
a4caffae2f
commit
31a1cc0541
|
@ -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 = {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
.sui-results-container {
|
||||
flex-grow: 1;
|
||||
padding: 0;
|
||||
|
||||
> li + li {
|
||||
margin-top: $euiSize;
|
||||
}
|
||||
}
|
||||
|
||||
.documentsSearchExperience__sidebar {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -11,6 +11,7 @@
|
|||
font-family: $euiCodeFontFamily;
|
||||
}
|
||||
|
||||
&--geolocation,
|
||||
&--location {
|
||||
color: $euiColorSuccessText;
|
||||
font-family: $euiCodeFontFamily;
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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('-');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue