Update Search Index Document Card design. (#194061)

## Summary



https://github.com/user-attachments/assets/552e1198-ba95-45d6-b13b-e1b26060d34c

This PR adds an option to add a richer design for Result card. 
Defaults still stay the same with old design.
Screenshot from Index Management.
<img width="1139" alt="Screenshot 2024-09-27 at 14 52 25"
src="https://github.com/user-attachments/assets/754a22c5-c3db-4385-b1ad-4e93a8615b9c">

Added a bunch of options to show score, show amount of fields to show
when collapsed by default etc.


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Efe Gürkan YALAMAN 2024-10-01 13:29:31 +02:00 committed by GitHub
parent e373e44377
commit 5bc33cd69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 614 additions and 250 deletions

View file

@ -9,7 +9,7 @@
import React, { useState } from 'react';
import { MappingProperty, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import type { IndicesGetMappingResponse, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import {
EuiButtonEmpty,
@ -30,18 +30,22 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { resultMetaData, resultToField } from './result/result_metadata';
import { resultMetaData, resultToFieldFromMappingResponse } from './result/result_metadata';
import { Result } from '..';
import { type ResultProps } from './result/result';
interface DocumentListProps {
dataTelemetryIdPrefix: string;
docs: SearchHit[];
docsPerPage: number;
isLoading: boolean;
mappings: Record<string, MappingProperty> | undefined;
mappings: IndicesGetMappingResponse | undefined;
meta: Pagination;
onPaginate: (newPageIndex: number) => void;
setDocsPerPage: (docsPerPage: number) => void;
setDocsPerPage?: (docsPerPage: number) => void;
onDocumentClick?: (doc: SearchHit) => void;
resultProps?: Partial<ResultProps>;
}
export const DocumentList: React.FC<DocumentListProps> = ({
@ -53,6 +57,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
meta,
onPaginate,
setDocsPerPage,
onDocumentClick,
resultProps = {},
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@ -99,7 +105,12 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{docs.map((doc) => {
return (
<React.Fragment key={doc._id}>
<Result fields={resultToField(doc, mappings)} metaData={resultMetaData(doc)} />
<Result
fields={resultToFieldFromMappingResponse(doc, mappings)}
metaData={resultMetaData(doc)}
onDocumentClick={onDocumentClick ? () => onDocumentClick(doc) : undefined}
{...resultProps}
/>
<EuiSpacer size="s" />
</React.Fragment>
);
@ -116,81 +127,83 @@ export const DocumentList: React.FC<DocumentListProps> = ({
onPageClick={onPaginate}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
aria-label={i18n.translate('searchIndexDocuments.documentList.docsPerPage', {
defaultMessage: 'Document count per page dropdown',
})}
button={
<EuiButtonEmpty
data-telemetry-id={`${dataTelemetryIdPrefix}-documents-docsPerPage`}
{setDocsPerPage && (
<EuiFlexItem grow={false}>
<EuiPopover
aria-label={i18n.translate('searchIndexDocuments.documentList.docsPerPage', {
defaultMessage: 'Document count per page dropdown',
})}
button={
<EuiButtonEmpty
data-telemetry-id={`${dataTelemetryIdPrefix}-documents-docsPerPage`}
size="s"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(true);
}}
>
{i18n.translate('searchIndexDocuments.documentList.pagination.itemsPerPage', {
defaultMessage: 'Documents per page: {docPerPage}',
values: { docPerPage: docsPerPage },
})}
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(true);
}}
>
{i18n.translate('searchIndexDocuments.documentList.pagination.itemsPerPage', {
defaultMessage: 'Documents per page: {docPerPage}',
values: { docPerPage: docsPerPage },
})}
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
key="10 rows"
icon={getIconType(10)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(10);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 10 },
})}
</EuiContextMenuItem>,
items={[
<EuiContextMenuItem
key="10 rows"
icon={getIconType(10)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(10);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 10 },
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="25 rows"
icon={getIconType(25)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(25);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 25 },
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="50 rows"
icon={getIconType(50)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(50);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 50 },
})}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
<EuiContextMenuItem
key="25 rows"
icon={getIconType(25)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(25);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 25 },
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="50 rows"
icon={getIconType(50)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(50);
}}
>
{i18n.translate('searchIndexDocuments.documentList.paginationOptions.option', {
defaultMessage: '{docCount} documents',
values: { docCount: 50 },
})}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />

View file

@ -58,8 +58,14 @@ describe('DocumentList', () => {
},
],
mappings: {
AvgTicketPrice: {
type: 'float' as const,
kibana_sample_data_flights: {
mappings: {
properties: {
AvgTicketPrice: {
type: 'float' as const,
},
},
},
},
},
};

View file

@ -8,4 +8,8 @@
*/
export { Result } from './result';
export { resultMetaData, resultToField } from './result_metadata';
export {
resultMetaData,
resultToFieldFromMappingResponse,
resultToFieldFromMappings as resultToField,
} from './result_metadata';

View file

@ -9,76 +9,147 @@
import React, { useState } from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip } from '@elastic/eui';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ResultFields } from './results_fields';
import { ResultHeader } from './result_header';
import './result.scss';
import { MetaDataProps, ResultFieldProps } from './result_types';
import { RichResultHeader } from './rich_result_header';
import { ResultHeader } from './result_header';
interface ResultProps {
export const DEFAULT_VISIBLE_FIELDS = 3;
export interface ResultProps {
fields: ResultFieldProps[];
metaData: MetaDataProps;
defaultVisibleFields?: number;
showScore?: boolean;
compactCard?: boolean;
onDocumentClick?: () => void;
}
export const Result: React.FC<ResultProps> = ({ metaData, fields }) => {
export const Result: React.FC<ResultProps> = ({
metaData,
fields,
defaultVisibleFields = DEFAULT_VISIBLE_FIELDS,
compactCard = true,
showScore = false,
onDocumentClick,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const tooltipText =
fields.length <= 3
fields.length <= defaultVisibleFields
? i18n.translate('searchIndexDocuments.result.expandTooltip.allVisible', {
defaultMessage: 'All fields are visible',
})
: isExpanded
? i18n.translate('searchIndexDocuments.result.expandTooltip.showFewer', {
defaultMessage: 'Show {amount} fewer fields',
values: { amount: fields.length - 3 },
values: { amount: fields.length - defaultVisibleFields },
})
: i18n.translate('searchIndexDocuments.result.expandTooltip.showMore', {
defaultMessage: 'Show {amount} more fields',
values: { amount: fields.length - 3 },
values: { amount: fields.length - defaultVisibleFields },
});
const toolTipContent = <>{tooltipText}</>;
return (
<EuiPanel hasBorder paddingSize="s" data-test-subj="search-index-documents-result">
<EuiPanel
hasBorder
data-test-subj="search-index-documents-result"
paddingSize={compactCard ? 's' : 'l'}
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
justifyContent="spaceAround"
>
<EuiFlexItem grow={false}>
<ResultHeader
title={
metaData.title ??
i18n.translate('searchIndexDocuments.result.title.id', {
defaultMessage: 'Document id: {id}',
values: { id: metaData.id },
})
}
metaData={metaData}
/>
{compactCard && (
<ResultHeader
title={
metaData.title ??
i18n.translate('searchIndexDocuments.result.title.id', {
defaultMessage: 'Document id: {id}',
values: { id: metaData.id },
})
}
metaData={metaData}
/>
)}
{!compactCard && (
<RichResultHeader
showScore={showScore}
title={
metaData.title ??
i18n.translate('searchIndexDocuments.result.title.id', {
defaultMessage: 'Document id: {id}',
values: { id: metaData.id },
})
}
onTitleClick={onDocumentClick}
metaData={metaData}
rightSideActions={
<EuiFlexItem grow={false}>
<EuiToolTip position="left" content={toolTipContent}>
<EuiButtonIcon
iconType={isExpanded ? 'fold' : 'unfold'}
color={isExpanded ? 'danger' : 'primary'}
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
aria-label={tooltipText}
/>
</EuiToolTip>
</EuiFlexItem>
}
/>
)}
</EuiFlexItem>
<EuiFlexItem>
{!compactCard &&
((isExpanded && fields.length > 0) ||
(!isExpanded && fields.slice(0, defaultVisibleFields).length > 0)) && (
<EuiSpacer size="l" />
)}
<ResultFields
isExpanded={isExpanded}
fields={isExpanded ? fields : fields.slice(0, 3)}
fields={isExpanded ? fields : fields.slice(0, defaultVisibleFields)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div className="resultExpandColumn">
<EuiToolTip position="left" content={toolTipContent}>
<EuiButtonIcon
iconType={isExpanded ? 'fold' : 'unfold'}
color="text"
onClick={() => setIsExpanded(!isExpanded)}
aria-label={tooltipText}
/>
</EuiToolTip>
</div>
</EuiFlexItem>
{compactCard && (
<EuiFlexItem grow={false}>
<div className="resultExpandColumn">
<EuiToolTip position="left" content={toolTipContent}>
<EuiButtonIcon
iconType={isExpanded ? 'fold' : 'unfold'}
color="text"
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
aria-label={tooltipText}
/>
</EuiToolTip>
</div>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
);

View file

@ -7,7 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { MappingProperty, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import type {
IndicesGetMappingResponse,
MappingProperty,
SearchHit,
} from '@elastic/elasticsearch/lib/api/types';
import type { MetaDataProps } from './result_types';
const TITLE_KEYS = ['title', 'name'];
@ -37,15 +41,19 @@ export const resultTitle = (result: SearchHit): string | undefined => {
export const resultMetaData = (result: SearchHit): MetaDataProps => ({
id: result._id!,
title: resultTitle(result),
score: result._score,
});
export const resultToField = (result: SearchHit, mappings?: Record<string, MappingProperty>) => {
if (mappings && result._source && !Array.isArray(result._source)) {
export const resultToFieldFromMappingResponse = (
result: SearchHit,
mappings?: IndicesGetMappingResponse
) => {
if (mappings && mappings[result._index] && result._source && !Array.isArray(result._source)) {
if (typeof result._source === 'object') {
return Object.entries(result._source).map(([key, value]) => {
return {
fieldName: key,
fieldType: mappings[key]?.type ?? 'object',
fieldType: mappings[result._index]?.mappings?.properties?.[key]?.type ?? 'object',
fieldValue: JSON.stringify(value, null, 2),
};
});
@ -53,3 +61,19 @@ export const resultToField = (result: SearchHit, mappings?: Record<string, Mappi
}
return [];
};
export const resultToFieldFromMappings = (
result: SearchHit,
mappings?: Record<string, MappingProperty>
) => {
if (mappings && result._source && !Array.isArray(result._source)) {
return Object.entries(result._source).map(([key, value]) => {
return {
fieldName: key,
fieldType: mappings[key]?.type ?? 'object',
fieldValue: JSON.stringify(value, null, 2),
};
});
}
return [];
};

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { IconType } from '@elastic/eui';
export interface ResultFieldProps {
@ -20,4 +21,6 @@ export interface MetaDataProps {
id: string;
onDocumentDelete?: Function;
title?: string;
score?: SearchHit['_score'];
showScore?: boolean;
}

View file

@ -0,0 +1,228 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPanel,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiText,
EuiTextColor,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { MetaDataProps } from './result_types';
interface Props {
metaData: MetaDataProps;
title: string;
rightSideActions?: React.ReactNode;
showScore?: boolean;
onTitleClick?: () => void;
}
interface TermDef {
label: string | number;
}
const Term: React.FC<TermDef> = ({ label }) => (
<EuiFlexItem grow={false}>
<strong>
<EuiTextColor color="subdued">{label}:</EuiTextColor>
</strong>
</EuiFlexItem>
);
const Definition: React.FC<TermDef> = ({ label }) => (
<EuiFlexItem>
<EuiTextColor color="subdued">{label}</EuiTextColor>
</EuiFlexItem>
);
const MetadataPopover: React.FC<MetaDataProps> = ({
id,
onDocumentDelete,
score,
showScore = false,
}) => {
const [popoverIsOpen, setPopoverIsOpen] = useState(false);
const closePopover = () => setPopoverIsOpen(false);
const metaDataIcon = (
<EuiButtonIcon
display="empty"
size="xs"
iconType="iInCircle"
color="primary"
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setPopoverIsOpen(!popoverIsOpen);
}}
aria-label={i18n.translate('searchIndexDocuments.result.header.metadata.icon.ariaLabel', {
defaultMessage: 'Metadata for document: {id}',
values: { id },
})}
/>
);
return (
<EuiPopover button={metaDataIcon} isOpen={popoverIsOpen} closePopover={closePopover}>
<EuiPopoverTitle>
{i18n.translate('searchIndexDocuments.result.header.metadata.title', {
defaultMessage: 'Document metadata',
})}
</EuiPopoverTitle>
<EuiFlexGroup
gutterSize="s"
direction="column"
css={css`
width: 20rem;
`}
>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="s">
<Term label="ID" />
<Definition label={id} />
</EuiFlexGroup>
</EuiFlexItem>
{score && showScore && (
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="s">
<Term
label={i18n.translate('searchIndexDocuments.result.header.metadata.score', {
defaultMessage: 'Score',
})}
/>
<Definition label={score?.toString()} />
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
{onDocumentDelete && (
<EuiPopoverFooter>
<EuiButton
iconType="trash"
color="danger"
size="s"
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
closePopover();
}}
fullWidth
>
{i18n.translate('searchIndexDocuments.result.header.metadata.deleteDocument', {
defaultMessage: 'Delete document',
})}
</EuiButton>
</EuiPopoverFooter>
)}
</EuiPopover>
);
};
const Score: React.FC<{ score: MetaDataProps['score'] }> = ({ score }) => {
return (
<EuiPanel paddingSize="xs" hasShadow={false} color="subdued" grow>
<EuiFlexGroup
direction="column"
responsive={false}
alignItems="center"
justifyContent="center"
gutterSize="s"
>
<EuiFlexItem grow>
<EuiIcon type="visGauge" size="m" />
</EuiFlexItem>
<EuiFlexItem grow>
<EuiPanel
hasShadow={false}
hasBorder={false}
css={css`
inline-size: 5ch;
max-inline-size: 100%;
`}
paddingSize="none"
color="subdued"
>
<EuiText textAlign="center" size="xs">
{score ? score.toString().substring(0, 5) : '-'}
</EuiText>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
export const RichResultHeader: React.FC<Props> = ({
title,
metaData,
rightSideActions = null,
showScore = false,
onTitleClick,
}) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexItem
grow
css={css`
min-height: ${euiTheme.base * 3}px;
max-height: ${euiTheme.base * 8}px;
`}
>
<EuiFlexGroup gutterSize="m" alignItems="center">
{showScore && (
<EuiFlexItem grow={false}>
<Score score={metaData.score} />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiText>
<EuiFlexGroup alignItems="center" gutterSize="l" responsive={false}>
<EuiFlexItem>
{onTitleClick ? (
<EuiLink onClick={onTitleClick} color="text">
<EuiTitle size="xs">
<h4>{title}</h4>
</EuiTitle>
</EuiLink>
) : (
<EuiTitle size="xs">
<h4>{title}</h4>
</EuiTitle>
)}
</EuiFlexItem>
{!!metaData && (
<EuiFlexItem grow={false}>
<MetadataPopover {...metaData} showScore={showScore} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{rightSideActions}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -122,7 +122,7 @@ export const SearchIndexDocuments: React.FC = () => {
docs={docs}
docsPerPage={pagination.pageSize ?? 10}
isLoading={status !== Status.SUCCESS && mappingStatus !== Status.SUCCESS}
mappings={mappingData?.mappings?.properties ?? {}}
mappings={mappingData ? { [indexName]: mappingData } : undefined}
meta={data?.meta ?? DEFAULT_PAGINATION}
onPaginate={(pageIndex) => setPagination({ ...pagination, pageIndex })}
setDocsPerPage={(pageSize) => setPagination({ ...pagination, pageSize })}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { MappingProperty, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { Result, resultToField, resultMetaData } from '@kbn/search-index-documents';
import { Result, resultMetaData, resultToField } from '@kbn/search-index-documents';
import { EuiSpacer } from '@elastic/eui';

View file

@ -51,6 +51,7 @@ export enum APIRoutes {
POST_QUERY_SOURCE_FIELDS = '/internal/search_playground/query_source_fields',
GET_INDICES = '/internal/search_playground/indices',
POST_SEARCH_QUERY = '/internal/search_playground/search',
GET_INDEX_MAPPINGS = '/internal/search_playground/mappings',
}
export enum LLMs {

View file

@ -106,7 +106,11 @@ export const App: React.FC<AppProps> = ({
css={{
position: 'relative',
}}
contentProps={{ css: { display: 'flex', flexGrow: 1, position: 'absolute', inset: 0 } }}
contentProps={
selectedPageMode === PlaygroundPageMode.search && selectedMode === 'chat'
? undefined
: { css: { display: 'flex', flexGrow: 1, position: 'absolute', inset: 0 } }
}
paddingSize={paddingSize}
className="eui-fullHeight"
>

View file

@ -1,34 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export interface EmptyResultsArgs {
query?: string;
}
export const EmptyResults: React.FC<EmptyResultsArgs> = ({ query }) => {
return (
<EuiEmptyPrompt
body={
<p>
{query
? i18n.translate('xpack.searchPlayground.resultList.emptyWithQuery.text', {
defaultMessage: 'No result found for: {query}',
values: { query },
})
: i18n.translate('xpack.searchPlayground.resultList.empty.text', {
defaultMessage: 'No results found',
})}
</p>
}
/>
);
};

View file

@ -7,40 +7,29 @@
import React, { useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPagination,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { UnifiedDocViewerFlyout } from '@kbn/unified-doc-viewer-plugin/public';
import { DocumentList, pageToPagination } from '@kbn/search-index-documents';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { EsHitRecord } from '@kbn/discover-utils/types';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import type { IndicesGetMappingResponse, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { buildDataTableRecord } from '@kbn/discover-utils';
import { i18n } from '@kbn/i18n';
import { Pagination } from '../../types';
import { getPageCounts } from '../../utils/pagination_helper';
import { EmptyResults } from './empty_results';
import { UnifiedDocViewerFlyout } from '@kbn/unified-doc-viewer-plugin/public';
import { Pagination as PaginationTypeEui } from '@elastic/eui';
import { useKibana } from '../../hooks/use_kibana';
import { Pagination } from '../../types';
export interface ResultListArgs {
searchResults: SearchHit[];
mappings?: IndicesGetMappingResponse;
pagination: Pagination;
onPaginationChange: (nextPage: number) => void;
searchQuery?: string;
}
export const ResultList: React.FC<ResultListArgs> = ({
searchResults,
mappings,
pagination,
onPaginationChange,
searchQuery = '',
}) => {
const {
services: { data },
@ -50,73 +39,42 @@ export const ResultList: React.FC<ResultListArgs> = ({
data.dataViews.getDefaultDataView().then((d) => setDataView(d));
}, [data]);
const [flyoutDocId, setFlyoutDocId] = useState<string | undefined>(undefined);
const { totalPage, page } = getPageCounts(pagination);
const documentMeta: PaginationTypeEui = pageToPagination(pagination);
const hit =
flyoutDocId &&
buildDataTableRecord(searchResults.find((item) => item._id === flyoutDocId) as EsHitRecord);
return (
<EuiPanel grow={false}>
<EuiFlexGroup direction="column" gutterSize="none">
{searchResults.length === 0 && (
<EuiFlexItem>
<EmptyResults query={searchQuery} />
</EuiFlexItem>
)}
{searchResults.length !== 0 &&
searchResults.map((item, index) => {
return (
<>
<EuiFlexItem
key={item._id + '-' + index}
onClick={() => setFlyoutDocId(item._id)}
grow
>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow>
<EuiTitle size="xs">
<h3>ID:{item._id}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiText size="s">
<p>
{i18n.translate('xpack.searchPlayground.resultList.result.score', {
defaultMessage: 'Document score: {score}',
values: { score: item._score },
})}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{index !== searchResults.length - 1 && <EuiHorizontalRule margin="m" />}
</>
);
})}
{searchResults.length !== 0 && (
<EuiFlexItem>
<EuiPagination
pageCount={totalPage}
activePage={page}
onPageClick={onPaginationChange}
/>
</EuiFlexItem>
)}
{flyoutDocId && dataView && hit && (
<UnifiedDocViewerFlyout
services={{}}
onClose={() => setFlyoutDocId(undefined)}
isEsqlQuery={false}
columns={[]}
hit={hit}
dataView={dataView}
onAddColumn={() => {}}
onRemoveColumn={() => {}}
setExpandedDoc={() => {}}
flyoutType="overlay"
/>
)}
</EuiFlexGroup>
</EuiPanel>
<>
<DocumentList
dataTelemetryIdPrefix="result-list"
docs={searchResults}
docsPerPage={10}
isLoading={false}
mappings={mappings}
meta={documentMeta}
onPaginate={onPaginationChange}
onDocumentClick={(searchHit: SearchHit) => setFlyoutDocId(searchHit._id)}
resultProps={{
showScore: true,
compactCard: false,
defaultVisibleFields: 0,
}}
/>
{flyoutDocId && dataView && hit && (
<UnifiedDocViewerFlyout
services={{}}
onClose={() => setFlyoutDocId(undefined)}
isEsqlQuery={false}
columns={[]}
hit={hit}
dataView={dataView}
onAddColumn={() => {}}
onRemoveColumn={() => {}}
setExpandedDoc={() => {}}
flyoutType="overlay"
/>
)}
</>
);
};

View file

@ -23,6 +23,7 @@ import { ResultList } from './result_list';
import { ChatForm, ChatFormFields, Pagination } from '../../types';
import { useSearchPreview } from '../../hooks/use_search_preview';
import { getPaginationFromPage } from '../../utils/pagination_helper';
import { useIndexMappings } from '../../hooks/use_index_mappings';
export const SearchMode: React.FC = () => {
const { euiTheme } = useEuiTheme();
@ -40,6 +41,7 @@ export const SearchMode: React.FC = () => {
}>({ query: searchBarValue, pagination: DEFAULT_PAGINATION });
const { results, pagination } = useSearchPreview(searchQuery);
const { data: mappingData } = useIndexMappings();
const queryClient = useQueryClient();
const handleSearch = async (query = searchBarValue, paginationParam = DEFAULT_PAGINATION) => {
@ -81,15 +83,15 @@ export const SearchMode: React.FC = () => {
/>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem className="eui-yScroll">
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{searchQuery.query ? (
<ResultList
searchResults={results}
mappings={mappingData}
pagination={pagination}
onPaginationChange={onPagination}
searchQuery={searchQuery.query}
/>
) : (
<EuiEmptyPrompt

View file

@ -0,0 +1,45 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { HttpSetup } from '@kbn/core-http-browser';
import { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types';
import { useFormContext } from 'react-hook-form';
import { APIRoutes, ChatForm, ChatFormFields } from '../types';
import { useKibana } from './use_kibana';
export interface FetchIndexMappingsArgs {
indices: ChatForm[ChatFormFields.indices];
http: HttpSetup;
}
export const fetchIndexMappings = async ({ indices, http }: FetchIndexMappingsArgs) => {
const mappings = await http.post<{
mappings: IndicesGetMappingResponse;
}>(APIRoutes.GET_INDEX_MAPPINGS, {
body: JSON.stringify({
indices,
}),
});
return mappings;
};
export const useIndexMappings = () => {
const {
services: { http },
} = useKibana();
const { getValues } = useFormContext();
const indices = getValues(ChatFormFields.indices);
const { data } = useQuery({
queryKey: ['search-playground-index-mappings'],
queryFn: () => fetchIndexMappings({ indices, http }),
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
return { data: data?.mappings };
};

View file

@ -8,6 +8,7 @@
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { useQuery } from '@tanstack/react-query';
import { useFormContext } from 'react-hook-form';
import type { HttpSetup } from '@kbn/core-http-browser';
import { APIRoutes, ChatForm, ChatFormFields, Pagination } from '../types';
import { useKibana } from './use_kibana';
import { DEFAULT_PAGINATION } from '../../common';
@ -17,7 +18,7 @@ export interface FetchSearchResultsArgs {
pagination: Pagination;
indices: ChatForm[ChatFormFields.indices];
elasticsearchQuery: ChatForm[ChatFormFields.elasticsearchQuery];
http: ReturnType<typeof useKibana>['services']['http'];
http: HttpSetup;
}
interface UseSearchPreviewData {
@ -64,9 +65,10 @@ export const useSearchPreview = ({
query: string;
pagination: Pagination;
}) => {
const { services } = useKibana();
const {
services: { http },
} = useKibana();
const { getValues } = useFormContext();
const { http } = services;
const indices = getValues(ChatFormFields.indices);
const elasticsearchQuery = getValues(ChatFormFields.elasticsearchQuery);

View file

@ -7,13 +7,6 @@
import { Pagination } from '../../common/types';
export const getPageCounts = (pagination: Pagination) => {
const { total, from, size } = pagination;
const totalPage = Math.ceil(total / size);
const page = Math.floor(from / size);
return { totalPage, total, page, size };
};
export const getPaginationFromPage = (page: number, size: number, previousValue: Pagination) => {
const from = page < 0 ? 0 : page * size;
return { ...previousValue, from, size, page };

View file

@ -305,4 +305,47 @@ export function defineRoutes({
}
})
);
router.post(
{
path: APIRoutes.GET_INDEX_MAPPINGS,
validate: {
body: schema.object({
indices: schema.arrayOf(schema.string()),
}),
},
},
errorHandler(logger)(async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const { indices } = request.body;
try {
if (indices.length === 0) {
return response.badRequest({
body: {
message: 'Indices cannot be empty',
},
});
}
const mappings = await client.asCurrentUser.indices.getMapping({
index: indices,
});
return response.ok({
body: {
mappings,
},
});
} catch (e) {
logger.error('Failed to get index mappings', e);
if (typeof e === 'object' && e.message) {
return response.badRequest({
body: {
message: e.message,
},
});
}
throw e;
}
})
);
}

View file

@ -44,7 +44,8 @@
"@kbn/unified-doc-viewer-plugin",
"@kbn/data-views-plugin",
"@kbn/discover-utils",
"@kbn/data-plugin"
"@kbn/data-plugin",
"@kbn/search-index-documents"
],
"exclude": [
"target/**/*",

View file

@ -63,7 +63,7 @@ export const IndexDocuments: React.FC<IndexDocumentsProps> = ({ indexName }) =>
docs={docs}
docsPerPage={pagination.pageSize ?? 10}
isLoading={false}
mappings={mappingData?.mappings?.properties ?? {}}
mappings={mappingData ? { [indexName]: mappingData } : undefined}
meta={documentsMeta ?? DEFAULT_PAGINATION}
onPaginate={(pageIndex) => setPagination({ ...pagination, pageIndex })}
setDocsPerPage={(pageSize) => setPagination({ ...pagination, pageSize })}