[Enterprise Search] Add missing pagination to documents (#137998)

* Adds base FE implementation for documents list

* Add backend for pagination.

* Add tests

* Use pagination instead of meta

* Use 0 indexed pagination

* Fix axe error
This commit is contained in:
Efe Gürkan YALAMAN 2022-08-03 18:13:09 +02:00 committed by GitHub
parent b17579afa3
commit e3760c580a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 674 additions and 71 deletions

View file

@ -96,3 +96,5 @@ export const ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID = 'ent-search-audit-logs';
export const APP_SEARCH_URL = '/app/enterprise_search/app_search';
export const ENTERPRISE_SEARCH_ELASTICSEARCH_URL = '/app/enterprise_search/elasticsearch';
export const WORKPLACE_SEARCH_URL = '/app/enterprise_search/workplace_search';
export const ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT = 25;

View file

@ -7,19 +7,31 @@
import { SearchResponseBody } from '@elastic/elasticsearch/lib/api/types';
import { Meta } from '../../../../../common/types';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export const searchDocuments = async ({
docsPerPage,
indexName,
query,
pagination,
query: q,
}: {
docsPerPage?: number;
indexName: string;
pagination: { pageIndex: number; pageSize: number; totalItemCount: number };
query: string;
}) => {
const route = `/internal/enterprise_search/indices/${indexName}/search/${query}`;
const route = `/internal/enterprise_search/indices/${indexName}/search/${q}`;
const query = {
page: pagination.pageIndex,
size: docsPerPage || pagination.pageSize,
};
return await HttpLogic.values.http.get<SearchResponseBody>(route);
return await HttpLogic.values.http.get<{ meta: Meta; results: SearchResponseBody }>(route, {
query,
});
};
export const SearchDocumentsApiLogic = createApiLogic(

View file

@ -0,0 +1,97 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCallOut, EuiPagination } from '@elastic/eui';
import { Status } from '../../../../../../../common/types/api';
import { Result } from '../../../../../shared/result/result';
import { INDEX_DOCUMENTS_META_DEFAULT } from '../../documents_logic';
import { DocumentList } from './document_list';
const mockActions = {};
export const DEFAULT_VALUES = {
data: undefined,
indexName: 'indexName',
isLoading: true,
mappingData: undefined,
mappingStatus: 0,
meta: INDEX_DOCUMENTS_META_DEFAULT,
query: '',
results: [],
status: Status.IDLE,
};
const mockValues = { ...DEFAULT_VALUES };
describe('DocumentList', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues(mockValues);
setMockActions(mockActions);
});
it('renders empty', () => {
const wrapper = shallow(<DocumentList />);
expect(wrapper.find(Result)).toHaveLength(0);
expect(wrapper.find(EuiPagination)).toHaveLength(2);
});
it('renders documents when results when there is data and mappings', () => {
setMockValues({
...mockValues,
results: [
{
_id: 'M9ntXoIBTq5dF-1Xnc8A',
_index: 'kibana_sample_data_flights',
_score: 1,
_source: {
AvgTicketPrice: 268.24159591388866,
},
},
{
_id: 'NNntXoIBTq5dF-1Xnc8A',
_index: 'kibana_sample_data_flights',
_score: 1,
_source: {
AvgTicketPrice: 68.91388866,
},
},
],
simplifiedMapping: {
AvgTicketPrice: {
type: 'float',
},
},
});
const wrapper = shallow(<DocumentList />);
expect(wrapper.find(Result)).toHaveLength(2);
});
it('renders callout when total results are 10.000', () => {
setMockValues({
...mockValues,
meta: {
page: {
...INDEX_DOCUMENTS_META_DEFAULT.page,
total_results: 10000,
},
},
});
const wrapper = shallow(<DocumentList />);
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
});
});

View file

@ -0,0 +1,207 @@
/*
* 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, { useState } from 'react';
import { useActions, useValues } from 'kea';
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import {
EuiButtonEmpty,
EuiCallOut,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPagination,
EuiProgress,
EuiPopover,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Result } from '../../../../../shared/result/result';
import { DocumentsLogic } from '../../documents_logic';
export const DocumentList: React.FC = () => {
const {
docsPerPage,
isLoading,
meta,
results,
simplifiedMapping: mappings,
} = useValues(DocumentsLogic);
const { onPaginate, setDocsPerPage } = useActions(DocumentsLogic);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const resultToField = (result: SearchHit) => {
if (mappings && 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',
fieldValue: JSON.stringify(value, null, 2),
};
});
}
}
return [];
};
const docsPerPageButton = (
<EuiButtonEmpty
size="s"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(true);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.pagination.itemsPerPage',
{
defaultMessage: 'Documents per page: {docPerPage}',
values: { docPerPage: docsPerPage },
}
)}
</EuiButtonEmpty>
);
const getIconType = (size: number) => {
return size === docsPerPage ? 'check' : 'empty';
};
const docsPerPageOptions = [
<EuiContextMenuItem
key="10 rows"
icon={getIconType(10)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(10);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.paginationOptions.option',
{ defaultMessage: '{docCount} documents', values: { docCount: 10 } }
)}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="25 rows"
icon={getIconType(25)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(25);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.paginationOptions.option',
{ defaultMessage: '{docCount} documents', values: { docCount: 25 } }
)}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="50 rows"
icon={getIconType(50)}
onClick={() => {
setIsPopoverOpen(false);
setDocsPerPage(50);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.paginationOptions.option',
{ defaultMessage: '{docCount} documents', values: { docCount: 50 } }
)}
</EuiContextMenuItem>,
];
return (
<>
<EuiPagination
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.paginationAriaLabel',
{ defaultMessage: 'Pagination for document list' }
)}
pageCount={meta.page.total_pages}
activePage={meta.page.current}
onPageClick={onPaginate}
/>
<EuiSpacer size="m" />
<EuiText size="xs">
<p>
Showing <strong>{results.length}</strong> of <strong>{meta.page.total_results}</strong>.
Search results maxed at 10.000 documents.
</p>
</EuiText>
{isLoading && <EuiProgress size="xs" color="primary" />}
<EuiSpacer size="m" />
{results.map((result) => {
return (
<React.Fragment key={result._id}>
<Result
fields={resultToField(result)}
metaData={{
id: result._id,
}}
/>
<EuiSpacer size="s" />
</React.Fragment>
);
})}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiPagination
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.paginationAriaLabel',
{ defaultMessage: 'Pagination for document list' }
)}
pageCount={meta.page.total_pages}
activePage={meta.page.current}
onPageClick={onPaginate}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.docsPerPage',
{ defaultMessage: 'Document count per page dropdown' }
)}
button={docsPerPageButton}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={docsPerPageOptions} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{meta.page.total_results === 10000 && (
<EuiCallOut size="s" title="Results are limited to 10.000 documents" iconType="search">
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.documents.documentList.resultLimit',
{
defaultMessage:
'Only the first 10,000 results are available for paging. Please use the search bar to filter down your results.',
}
)}
</p>
</EuiCallOut>
)}
</>
);
};

View file

@ -9,53 +9,40 @@ import React, { useEffect, ChangeEvent } from 'react';
import { useActions, useValues } from 'kea';
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import {
EuiFieldSearch,
EuiTitle,
EuiSpacer,
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Result } from '../../../shared/result/result';
import { DocumentList } from './components/document_list/document_list';
import { DocumentsLogic, DEFAULT_PAGINATION } from './documents_logic';
import { DocumentsLogic } from './documents_logic';
import { IndexNameLogic } from './index_name_logic';
import './documents.scss';
export const SearchIndexDocuments: React.FC = () => {
const { indexName } = useValues(IndexNameLogic);
const { simplifiedMapping, results } = useValues(DocumentsLogic);
const { simplifiedMapping } = useValues(DocumentsLogic);
const { makeRequest, makeMappingRequest, setSearchQuery } = useActions(DocumentsLogic);
useEffect(() => {
makeRequest({ indexName, query: '' });
makeRequest({
indexName,
pagination: DEFAULT_PAGINATION,
query: '',
});
makeMappingRequest({ indexName });
}, [indexName]);
const resultToField = (result: SearchHit) => {
if (simplifiedMapping && result._source && !Array.isArray(result._source)) {
if (typeof result._source === 'object') {
return Object.entries(result._source).map(([key, value]) => {
return {
fieldName: key,
fieldType: simplifiedMapping[key]?.type ?? 'object',
fieldValue: JSON.stringify(value, null, 2),
};
});
}
}
return [];
};
return (
<EuiPanel hasBorder={false} hasShadow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none">
<EuiSpacer />
<EuiFlexGroup direction="column">
<EuiFlexItem>
@ -91,21 +78,7 @@ export const SearchIndexDocuments: React.FC = () => {
i18n.translate('xpack.enterpriseSearch.content.searchIndex.documents.noMappings', {
defaultMessage: 'No mappings found for index',
})}
{simplifiedMapping &&
results.map((result) => {
return (
<>
<Result
fields={resultToField(result)}
metaData={{
id: result._id,
}}
/>
<EuiSpacer size="s" />
</>
);
})}
{simplifiedMapping && <DocumentList />}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -10,19 +10,24 @@ import { LogicMounter, mockFlashMessageHelpers } from '../../../__mocks__/kea_lo
import { nextTick } from '@kbn/test-jest-helpers';
import { HttpError, Status } from '../../../../../common/types/api';
import { MappingsApiLogic } from '../../api/mappings/mappings_logic';
import { SearchDocumentsApiLogic } from '../../api/search_documents/search_documents_logic';
import { DocumentsLogic } from './documents_logic';
import {
DocumentsLogic,
INDEX_DOCUMENTS_META_DEFAULT,
convertMetaToPagination,
} from './documents_logic';
import { IndexNameLogic } from './index_name_logic';
const DEFAULT_VALUES = {
export const DEFAULT_VALUES = {
data: undefined,
docsPerPage: 25,
indexName: 'indexName',
isLoading: true,
mappingData: undefined,
mappingStatus: 0,
meta: INDEX_DOCUMENTS_META_DEFAULT,
query: '',
results: [],
status: Status.IDLE,
@ -57,6 +62,25 @@ describe('DocumentsLogic', () => {
expect(DocumentsLogic.values).toEqual({ ...DEFAULT_VALUES, query: newQueryString });
});
});
describe('setDocsPerPage', () => {
it('sets documents to show per page', () => {
const docsToShow = 50;
expect(DocumentsLogic.values).toEqual({ ...DEFAULT_VALUES });
DocumentsLogic.actions.setDocsPerPage(docsToShow);
expect(DocumentsLogic.values).toEqual({
...DEFAULT_VALUES,
docsPerPage: docsToShow,
meta: {
page: {
...INDEX_DOCUMENTS_META_DEFAULT.page,
size: docsToShow,
},
},
simplifiedMapping: undefined,
status: Status.LOADING,
});
});
});
});
describe('listeners', () => {
describe('setSearchQuery', () => {
@ -69,7 +93,9 @@ describe('DocumentsLogic', () => {
jest.advanceTimersByTime(250);
await nextTick();
expect(DocumentsLogic.actions.makeRequest).toHaveBeenCalledWith({
docsPerPage: 25,
indexName: 'indexName',
pagination: convertMetaToPagination(INDEX_DOCUMENTS_META_DEFAULT),
query: 'test',
});
jest.useRealTimers();
@ -84,7 +110,11 @@ describe('DocumentsLogic', () => {
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledTimes(1);
});
it('clears flash messages on new makeRequest', () => {
DocumentsLogic.actions.makeRequest({ indexName: 'index', query: '' });
DocumentsLogic.actions.makeRequest({
indexName: 'index',
pagination: convertMetaToPagination(INDEX_DOCUMENTS_META_DEFAULT),
query: '',
});
expect(mockFlashMessageHelpers.clearFlashMessages).toHaveBeenCalledTimes(1);
});
});
@ -99,11 +129,17 @@ describe('DocumentsLogic', () => {
};
expect(DocumentsLogic.values).toEqual({ ...DEFAULT_VALUES });
MappingsApiLogic.actions.apiSuccess({ mappings: {} });
SearchDocumentsApiLogic.actions.apiSuccess(mockSuccessData);
SearchDocumentsApiLogic.actions.apiSuccess({
meta: INDEX_DOCUMENTS_META_DEFAULT,
results: mockSuccessData,
});
expect(DocumentsLogic.values).toEqual({
...DEFAULT_VALUES,
data: mockSuccessData,
data: {
meta: INDEX_DOCUMENTS_META_DEFAULT,
results: mockSuccessData,
},
isLoading: false,
mappingData: {
mappings: {},
@ -135,17 +171,43 @@ describe('DocumentsLogic', () => {
};
MappingsApiLogic.actions.apiSuccess({ mappings: {} });
SearchDocumentsApiLogic.actions.apiSuccess(mockSuccessData);
SearchDocumentsApiLogic.actions.apiSuccess({
meta: {
page: {
...INDEX_DOCUMENTS_META_DEFAULT.page,
total_pages: 1,
total_results: 1,
},
},
results: mockSuccessData,
});
expect(DocumentsLogic.values).toEqual({
...DEFAULT_VALUES,
data: mockSuccessData,
data: {
meta: {
page: {
...INDEX_DOCUMENTS_META_DEFAULT.page,
total_pages: 1,
total_results: 1,
},
},
results: mockSuccessData,
},
isLoading: false,
mappingData: {
mappings: {},
},
mappingStatus: Status.SUCCESS,
meta: {
page: {
...INDEX_DOCUMENTS_META_DEFAULT.page,
total_pages: 1,
total_results: 1,
},
},
results: [{ _id: '123', _index: 'indexName', searchHit: true }],
simplifiedMapping: undefined,
status: Status.SUCCESS,
});
});
@ -158,11 +220,14 @@ describe('DocumentsLogic', () => {
};
MappingsApiLogic.actions.apiSuccess({ mappings: {} });
SearchDocumentsApiLogic.actions.apiSuccess(mockSuccessData);
SearchDocumentsApiLogic.actions.apiSuccess({
meta: INDEX_DOCUMENTS_META_DEFAULT,
results: mockSuccessData,
});
expect(DocumentsLogic.values).toEqual({
...DEFAULT_VALUES,
data: mockSuccessData,
data: { meta: INDEX_DOCUMENTS_META_DEFAULT, results: mockSuccessData },
isLoading: false,
mappingData: {
mappings: {},
@ -182,7 +247,7 @@ describe('DocumentsLogic', () => {
expect(DocumentsLogic.values).toEqual({
...DEFAULT_VALUES,
isLoading: false,
isLoading: true,
mappingData: {
mappings: { properties: { some: { type: 'text' } } },
},

View file

@ -14,44 +14,74 @@ import {
SearchHit,
} from '@elastic/elasticsearch/lib/api/types';
import { ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } from '../../../../../common/constants';
import { Meta } from '../../../../../common/types';
import { HttpError, Status } from '../../../../../common/types/api';
import { flashAPIErrors, clearFlashMessages } from '../../../shared/flash_messages';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { MappingsApiLogic } from '../../api/mappings/mappings_logic';
import { SearchDocumentsApiLogic } from '../../api/search_documents/search_documents_logic';
import { IndexNameLogic } from './index_name_logic';
export const INDEX_DOCUMENTS_META_DEFAULT = {
page: {
current: 0,
size: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
total_pages: 0,
total_results: 0,
},
};
export const DEFAULT_PAGINATION = {
pageIndex: INDEX_DOCUMENTS_META_DEFAULT.page.current,
pageSize: INDEX_DOCUMENTS_META_DEFAULT.page.size,
totalItemCount: INDEX_DOCUMENTS_META_DEFAULT.page.total_results,
};
interface DocumentsLogicActions {
apiError(error: HttpError): HttpError;
apiReset: typeof SearchDocumentsApiLogic.actions.apiReset;
makeMappingRequest: typeof MappingsApiLogic.actions.makeRequest;
makeRequest: typeof SearchDocumentsApiLogic.actions.makeRequest;
mappingsApiError(error: HttpError): HttpError;
onPaginate(newPageIndex: number): { newPageIndex: number };
setDocsPerPage(docsPerPage: number): { docsPerPage: number };
setSearchQuery(query: string): { query: string };
}
interface DocumentsLogicValues {
export interface DocumentsLogicValues {
data: typeof SearchDocumentsApiLogic.values.data;
docsPerPage: number;
indexName: typeof IndexNameLogic.values.indexName;
isLoading: boolean;
mappingData: IndicesGetMappingIndexMappingRecord;
mappingStatus: Status;
meta: Meta;
query: string;
results: SearchHit[];
simplifiedMapping: Record<string, MappingProperty> | undefined;
status: Status;
}
export const convertMetaToPagination = (meta: Meta) => ({
pageIndex: meta.page.current,
pageSize: meta.page.size,
totalItemCount: meta.page.total_results,
});
export const DocumentsLogic = kea<MakeLogicType<DocumentsLogicValues, DocumentsLogicActions>>({
actions: {
onPaginate: (newPageIndex) => ({ newPageIndex }),
setDocsPerPage: (docsPerPage) => ({ docsPerPage }),
setSearchQuery: (query) => ({ query }),
},
connect: {
actions: [
SearchDocumentsApiLogic,
['apiReset', 'makeRequest', 'apiError'],
['apiReset', 'makeRequest', 'apiError', 'apiSuccess'],
MappingsApiLogic,
['makeRequest as makeMappingRequest', 'apiError as mappingsApiError'],
],
@ -68,13 +98,50 @@ export const DocumentsLogic = kea<MakeLogicType<DocumentsLogicValues, DocumentsL
apiError: (e) => flashAPIErrors(e),
makeRequest: () => clearFlashMessages(),
mappingsApiError: (e) => flashAPIErrors(e),
onPaginate: () => {
actions.makeRequest({
docsPerPage: values.docsPerPage,
indexName: values.indexName,
pagination: convertMetaToPagination(values.meta),
query: values.query,
});
},
setDocsPerPage: () => {
actions.makeRequest({
docsPerPage: values.docsPerPage,
indexName: values.indexName,
pagination: convertMetaToPagination(values.meta),
query: values.query,
});
},
setSearchQuery: async (_, breakpoint) => {
await breakpoint(250);
actions.makeRequest({ indexName: values.indexName, query: values.query });
actions.makeRequest({
docsPerPage: values.docsPerPage,
indexName: values.indexName,
pagination: convertMetaToPagination(values.meta),
query: values.query,
});
},
}),
path: ['enterprise_search', 'search_index', 'documents'],
reducers: () => ({
docsPerPage: [
ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
{
setDocsPerPage: (_, { docsPerPage }) => docsPerPage,
},
],
meta: [
INDEX_DOCUMENTS_META_DEFAULT,
{
apiSuccess: (_, { meta }) => meta,
onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex),
setDocsPerPage: (_, { docsPerPage }) => ({
page: { ...INDEX_DOCUMENTS_META_DEFAULT.page, size: docsPerPage },
}),
},
],
query: [
'',
{
@ -85,14 +152,12 @@ export const DocumentsLogic = kea<MakeLogicType<DocumentsLogicValues, DocumentsL
selectors: ({ selectors }) => ({
isLoading: [
() => [selectors.status, selectors.mappingStatus],
(status, mappingStatus) => status !== Status.SUCCESS && mappingStatus !== Status.SUCCESS,
(status, mappingStatus) => status !== Status.SUCCESS || mappingStatus !== Status.SUCCESS,
],
results: [
() => [selectors.data, selectors.isLoading],
(data: SearchResponseBody, isLoading) => {
if (isLoading) return [];
return data?.hits?.hits || [];
() => [selectors.data],
(data: { results: SearchResponseBody }) => {
return data?.results?.hits?.hits || [];
},
],
simplifiedMapping: [

View file

@ -8,8 +8,11 @@
import { SearchResponseBody } from '@elastic/elasticsearch/lib/api/types';
import { IScopedClusterClient } from '@kbn/core/server';
import { ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } from '../../common/constants';
import { fetchSearchResults } from './fetch_search_results';
const DEFAULT_FROM_VALUE = 0;
describe('fetchSearchResults lib function', () => {
const mockClient = {
asCurrentUser: {
@ -82,8 +85,26 @@ describe('fetchSearchResults lib function', () => {
).resolves.toEqual(regularSearchResultsResponse);
expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({
from: DEFAULT_FROM_VALUE,
index: indexName,
q: query,
size: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
});
});
it('should return search results with hits when no query is passed', async () => {
mockClient.asCurrentUser.search.mockImplementation(
() => regularSearchResultsResponse as SearchResponseBody
);
await expect(
fetchSearchResults(mockClient as unknown as IScopedClusterClient, indexName)
).resolves.toEqual(regularSearchResultsResponse);
expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({
from: DEFAULT_FROM_VALUE,
index: indexName,
size: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
});
});
@ -97,8 +118,10 @@ describe('fetchSearchResults lib function', () => {
).resolves.toEqual(emptySearchResultsResponse);
expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({
from: DEFAULT_FROM_VALUE,
index: indexName,
q: query,
size: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
});
});
});

View file

@ -8,13 +8,19 @@
import { SearchResponseBody } from '@elastic/elasticsearch/lib/api/types';
import { IScopedClusterClient } from '@kbn/core/server';
import { ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } from '../../common/constants';
export const fetchSearchResults = async (
client: IScopedClusterClient,
indexName: string,
query?: string
query?: string,
from: number = 0,
size: number = ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT
): Promise<SearchResponseBody> => {
const results = await client.asCurrentUser.search({
from,
index: indexName,
size,
...(!!query ? { q: query } : {}),
});
return results;

View file

@ -16,7 +16,7 @@ import { fetchSearchResults } from '../../lib/fetch_search_results';
import { registerSearchRoute } from './search';
describe('Elasticsearch Index Mapping', () => {
describe('Elasticsearch Search', () => {
let mockRouter: MockRouter;
const mockClient = {};
@ -76,10 +76,104 @@ describe('Elasticsearch Index Mapping', () => {
params: { index_name: 'search-index-name', query: 'banana' },
});
expect(fetchSearchResults).toHaveBeenCalledWith(mockClient, 'search-index-name', 'banana');
expect(fetchSearchResults).toHaveBeenCalledWith(
mockClient,
'search-index-name',
'banana',
0,
25
);
expect(mockRouter.response.ok).toHaveBeenCalledWith({
body: mockData,
body: {
meta: {
page: {
current: 0,
size: 1,
total_pages: 1,
total_results: 1,
},
},
results: mockData,
},
headers: { 'content-type': 'application/json' },
});
});
});
describe('GET /internal/enterprise_search/indices/{index_name}/search', () => {
let mockRouterNoQuery: MockRouter;
beforeEach(() => {
const context = {
core: Promise.resolve({ elasticsearch: { client: mockClient } }),
} as jest.Mocked<RequestHandlerContext>;
mockRouterNoQuery = new MockRouter({
context,
method: 'get',
path: '/internal/enterprise_search/indices/{index_name}/search',
});
registerSearchRoute({
...mockDependencies,
router: mockRouterNoQuery.router,
});
});
it('fails validation without index_name', () => {
const request = { params: {} };
mockRouterNoQuery.shouldThrow(request);
});
it('searches returns first 25 search results by default', async () => {
const mockData = {
_shards: { failed: 0, skipped: 0, successful: 2, total: 2 },
hits: {
hits: [
{
_id: '5a12292a0f5ae10021650d7e',
_index: 'search-regular-index',
_score: 4.437291,
_source: { id: '5a12292a0f5ae10021650d7e', name: 'banana' },
},
],
max_score: null,
total: { relation: 'eq', value: 1 },
},
timed_out: false,
took: 4,
};
(fetchSearchResults as jest.Mock).mockImplementationOnce(() => {
return Promise.resolve(mockData);
});
await mockRouterNoQuery.callRoute({
params: { index_name: 'search-index-name' },
});
expect(fetchSearchResults).toHaveBeenCalledWith(
mockClient,
'search-index-name',
'banana',
0,
25
);
expect(mockRouterNoQuery.response.ok).toHaveBeenCalledWith({
body: {
meta: {
page: {
current: 0,
size: 1,
total_pages: 1,
total_results: 1,
},
},
results: mockData,
},
headers: { 'content-type': 'application/json' },
});
});

View file

@ -5,11 +5,36 @@
* 2.0.
*/
import { SearchResponseBody } from '@elastic/elasticsearch/lib/api/types';
import { schema } from '@kbn/config-schema';
import { ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } from '../../../common/constants';
import { fetchSearchResults } from '../../lib/fetch_search_results';
import { RouteDependencies } from '../../plugin';
const calculateMeta = (searchResults: SearchResponseBody, page: number, size: number) => {
let totalResults = 0;
if (searchResults.hits.total === null || searchResults.hits.total === undefined) {
totalResults = 0;
} else if (typeof searchResults.hits.total === 'number') {
totalResults = searchResults.hits.total;
} else {
totalResults = searchResults.hits.total.value;
}
const totalPages = Math.ceil(totalResults / size) || 1;
return {
page: {
current: page,
size: searchResults.hits.hits.length,
total_pages: (Number.isFinite(totalPages) && totalPages) || 1,
total_results: totalResults,
},
};
};
export function registerSearchRoute({ router }: RouteDependencies) {
router.get(
{
@ -18,14 +43,33 @@ export function registerSearchRoute({ router }: RouteDependencies) {
params: schema.object({
index_name: schema.string(),
}),
query: schema.object({
page: schema.number({ defaultValue: 0, min: 0 }),
size: schema.number({
defaultValue: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
min: 0,
}),
}),
},
},
async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const { page = 0, size = ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } = request.query;
const from = page * size;
try {
const searchResults = await fetchSearchResults(client, request.params.index_name, '');
const searchResults: SearchResponseBody = await fetchSearchResults(
client,
request.params.index_name,
'',
from,
size
);
return response.ok({
body: searchResults,
body: {
meta: calculateMeta(searchResults, page, size),
results: searchResults,
},
headers: { 'content-type': 'application/json' },
});
} catch (error) {
@ -44,18 +88,33 @@ export function registerSearchRoute({ router }: RouteDependencies) {
index_name: schema.string(),
query: schema.string(),
}),
query: schema.object({
page: schema.number({ defaultValue: 0, min: 0 }),
size: schema.number({
defaultValue: ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT,
min: 0,
}),
}),
},
},
async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const { page = 0, size = ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT } = request.query;
const from = page * size;
try {
const searchResults = await fetchSearchResults(
client,
request.params.index_name,
request.params.query
request.params.query,
from,
size
);
return response.ok({
body: searchResults,
body: {
meta: calculateMeta(searchResults, page, size),
results: searchResults,
},
headers: { 'content-type': 'application/json' },
});
} catch (error) {