[Enterprise Search] Engines list UI page - part 1 (#147399)

## Summary

Engine list UI page - part 1

This PR includes, 

1. Initial layout for Engine list UI page
2. Search bar to search via engine names or indices
3. Initial support for pagination
4. Test cases
5. Fetch engines list from mocked values 


<img width="1706" alt="Screen Shot 2022-12-12 at 5 35 04 PM"
src="https://user-images.githubusercontent.com/55930906/207169828-6713f836-0ed2-47ad-952e-9bbab6ac8f31.png">


TODO(s) (as per most recent UI
[design](https://github.com/elastic/enterprise-search-team/issues/3446#issuecomment-1341890967)
)

Will create separate PR to handle below scenarios. 

- Incorporate Flyout panel to list all the indices 
- Change to actual Backend API call to fetch engine list
- Add Fully functional Paginations  
- Add index health, Documents count, Last updated date when backend API  is ready
- Update test cases 

### 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)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Saarika Bhasi 2023-01-05 10:18:07 -05:00 committed by GitHub
parent 1b3769c86b
commit 8be155d295
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 595 additions and 3 deletions

View file

@ -0,0 +1,59 @@
/*
* 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 { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { EngineListDetails, Meta } from '../../components/engines/types';
export interface EnginesListAPIResponse {
results: EngineListDetails[];
meta: Meta;
searchQuery?: string;
}
export interface EnginesListAPIArguments {
meta: Meta;
searchQuery?: string;
}
const metaValue: Meta = {
from: 1,
size: 3,
total: 5,
};
// These are mocked values. To be changed as per the latest requirement when Backend API is ready
export const mockedEngines: EnginesListAPIResponse[] = [
{
meta: metaValue,
results: [
{
name: 'engine-name-1',
indices: ['index-18', 'index-23'],
last_updated: '21 March 2021',
document_count: 18,
},
{
name: 'engine-name-2',
indices: ['index-180', 'index-230', 'index-8', 'index-2'],
last_updated: '10 Jul 2018',
document_count: 10,
},
{
name: 'engine-name-3',
indices: ['index-2', 'index-3'],
last_updated: '21 December 2022',
document_count: 8,
},
],
},
];
export const fetchEngines = async () => {
// TODO replace with http call when backend is ready
return mockedEngines[0];
};
export const FetchEnginesAPILogic = createApiLogic(['content', 'engines_api_logic'], fetchEngines);

View file

@ -0,0 +1,113 @@
/*
* 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 { CriteriaWithPagination, EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedNumber } from '@kbn/i18n-react';
import { DELETE_BUTTON_LABEL, MANAGE_BUTTON_LABEL } from '../../../../../shared/constants';
import { convertMetaToPagination, EngineListDetails, Meta } from '../../types';
// add health status
interface EnginesListTableProps {
enginesList: EngineListDetails[];
loading: boolean;
meta: Meta;
isLoading?: boolean;
onChange: (criteria: CriteriaWithPagination<EngineListDetails>) => void;
}
export const EnginesListTable: React.FC<EnginesListTableProps> = ({
enginesList,
meta,
isLoading,
onChange,
}) => {
const columns: Array<EuiBasicTableColumn<EngineListDetails>> = [
{
field: 'name',
name: i18n.translate('xpack.enterpriseSearch.content.enginesList.table.column.name', {
defaultMessage: 'Engine Name',
}),
width: '30%',
truncateText: true,
mobileOptions: {
header: true,
enlarge: true,
width: '100%',
},
},
{
field: 'document_count',
name: i18n.translate('xpack.enterpriseSearch.content.enginesList.table.column.documents', {
defaultMessage: 'Documents',
}),
dataType: 'number',
render: (number: number) => <FormattedNumber value={number} />,
},
{
field: 'last_updated',
name: i18n.translate('xpack.enterpriseSearch.content.enginesList.table.column.lastUpdated', {
defaultMessage: 'Last updated',
}),
dataType: 'string',
},
{
field: 'indices.length',
datatype: 'number',
name: i18n.translate('xpack.enterpriseSearch.content.enginesList.table.column.indices', {
defaultMessage: 'Indices',
}),
},
{
name: i18n.translate('xpack.enterpriseSearch.content.enginesList.table.column.actions', {
defaultMessage: 'Actions',
}),
actions: [
{
name: MANAGE_BUTTON_LABEL,
description: i18n.translate(
'xpack.enterpriseSearch.content.enginesList.table.column.action.manage.buttonDescription',
{
defaultMessage: 'Manage this engine',
}
),
type: 'icon',
icon: 'eye',
onClick: () => {},
},
{
name: DELETE_BUTTON_LABEL,
description: i18n.translate(
'xpack.enterpriseSearch.content.enginesList.table.column.action.delete.buttonDescription',
{
defaultMessage: 'Delete this engine',
}
),
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: () => {},
},
],
},
];
return (
<EuiBasicTable
items={enginesList}
columns={columns}
pagination={{ ...convertMetaToPagination(meta), showPerPageOptions: false }}
onChange={onChange}
loading={isLoading}
/>
);
};

View file

@ -0,0 +1,153 @@
/*
* 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 { LogicMounter, mockFlashMessageHelpers } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { HttpError, Status } from '../../../../../common/types/api';
import { FetchEnginesAPILogic } from '../../api/engines/fetch_engines_api_logic';
import { EnginesListLogic } from './engines_list_logic';
import { DEFAULT_META, EngineListDetails } from './types';
const DEFAULT_VALUES = {
data: undefined,
results: [],
meta: DEFAULT_META,
parameters: { meta: DEFAULT_META },
status: Status.IDLE,
};
// sample engines list
const results: EngineListDetails[] = [
{
name: 'engine-name-1',
indices: ['index-18', 'index-23'],
last_updated: '21 March 2021',
document_count: 18,
},
{
name: 'engine-name-2',
indices: ['index-180', 'index-230', 'index-8', 'index-2'],
last_updated: '10 Jul 2018',
document_count: 10,
},
{
name: 'engine-name-3',
indices: ['index-2', 'index-3'],
last_updated: '21 December 2022',
document_count: 8,
},
];
describe('EnginesListLogic', () => {
const { mount: apiLogicMount } = new LogicMounter(FetchEnginesAPILogic);
const { mount } = new LogicMounter(EnginesListLogic);
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
apiLogicMount();
mount();
});
it('has expected default values', () => {
expect(EnginesListLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onPaginate', () => {
it('updates meta with newPageIndex', () => {
expect(EnginesListLogic.values).toEqual(DEFAULT_VALUES);
// This test does not work for now, test below code when Kibana GET API for pagination is ready
// EnginesListLogic.actions.onPaginate(2);
// expect(EnginesListLogic.values).toEqual({
// ...DEFAULT_VALUES,
// meta: {
// ...DEFAULT_META,
// from: 2,
// },
// });
});
});
});
describe('reducers', () => {
describe('meta', () => {
it('updates when apiSuccess', () => {
const newPageMeta = {
from: 2,
size: 3,
total: 6,
};
expect(EnginesListLogic.values).toEqual(DEFAULT_VALUES);
EnginesListLogic.actions.apiSuccess({
meta: newPageMeta,
results,
searchQuery: 'k',
});
expect(EnginesListLogic.values).toEqual({
...DEFAULT_VALUES,
data: {
results,
meta: newPageMeta,
searchQuery: 'k',
},
meta: newPageMeta,
parameters: {
meta: newPageMeta,
searchQuery: 'k',
},
results,
status: Status.SUCCESS,
});
});
});
});
describe('listeners', () => {
it('call flashAPIErrors on apiError', () => {
EnginesListLogic.actions.apiError({} as HttpError);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledTimes(1);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledWith({});
});
it('call makeRequest on fetchEngines', async () => {
jest.useFakeTimers({ legacyFakeTimers: true });
EnginesListLogic.actions.makeRequest = jest.fn();
EnginesListLogic.actions.fetchEngines({ meta: DEFAULT_META });
await nextTick();
expect(EnginesListLogic.actions.makeRequest).toHaveBeenCalledWith({
meta: DEFAULT_META,
});
});
});
describe('selectors', () => {
describe('enginesList', () => {
it('updates when apiSuccess', () => {
expect(EnginesListLogic.values).toEqual(DEFAULT_VALUES);
EnginesListLogic.actions.apiSuccess({
results,
meta: DEFAULT_META,
});
expect(EnginesListLogic.values).toEqual({
...DEFAULT_VALUES,
data: {
results,
meta: DEFAULT_META,
},
meta: DEFAULT_META,
parameters: {
meta: DEFAULT_META,
},
results,
status: Status.SUCCESS,
});
});
});
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { mockedEngines } from '../../api/engines/fetch_engines_api_logic';
import { EnginesListTable } from './components/tables/engines_table';
import { EnginesList } from './engines_list';
import { DEFAULT_META } from './types';
const mockValues = {
enginesList: mockedEngines,
meta: DEFAULT_META,
};
const mockActions = {
fetchEngines: jest.fn(),
onPaginate: jest.fn(),
};
describe('EnginesList', () => {
beforeEach(() => {
jest.clearAllMocks();
global.localStorage.clear();
});
describe('Empty state', () => {});
it('renders with Engines data ', async () => {
setMockValues(mockValues);
setMockActions(mockActions);
const wrapper = shallow(<EnginesList />);
expect(wrapper.find(EnginesListTable)).toHaveLength(1);
});
});

View file

@ -5,13 +5,35 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useActions, useValues } from 'kea';
import { EuiButton, EuiFieldSearch, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { DataPanel } from '../../../shared/data_panel/data_panel';
import { handlePageChange } from '../../../shared/table_pagination';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
export const EnginesList = () => {
import { EnginesListTable } from './components/tables/engines_table';
import { EnginesListLogic } from './engines_list_logic';
export const EnginesList: React.FC = () => {
const { fetchEngines, onPaginate } = useActions(EnginesListLogic);
const { meta, results } = useValues(EnginesListLogic);
const [searchQuery, setSearchValue] = useState('');
useEffect(() => {
fetchEngines({
meta,
searchQuery,
});
}, [meta, searchQuery]);
return (
<EnterpriseSearchContentPageTemplate
pageChrome={[
@ -20,13 +42,98 @@ export const EnginesList = () => {
}),
]}
pageHeader={{
pageTitle: i18n.translate('xpack.enterpriseSearch.content.engines.headerTitle', {
pageTitle: i18n.translate('xpack.enterpriseSearch.content.engines.title', {
defaultMessage: 'Engines',
}),
rightSideItems: [
<EuiButton
fill
iconType="plusInCircle"
data-test-subj="enterpriseSearchContentEnginesCreationButton"
href={'TODO'}
>
{i18n.translate('xpack.enterpriseSearch.content.engines.createEngineButtonLabel', {
defaultMessage: 'Create engine',
})}
</EuiButton>,
],
}}
pageViewTelemetry="Engines"
isLoading={false}
>
<EuiText>
{i18n.translate('xpack.enterpriseSearch.content.engines.description', {
defaultMessage:
'Engines allow you to query indexed data with a complete set of relevance, analytics and personalization tools. To learn more about how engines work in Enterprise search ',
})}
<EuiLink data-test-subj="documentationLink" href="TODO" target="_blank">
{i18n.translate('xpack.enterpriseSearch.content.engines.documentation', {
defaultMessage: 'explore our Engines documentation',
})}
</EuiLink>
</EuiText>
<EuiSpacer />
<div>
<EuiFieldSearch
value={searchQuery}
placeholder={i18n.translate('xpack.enterpriseSearch.content.engines.searchPlaceholder', {
defaultMessage: 'Search engines',
})}
aria-label={i18n.translate('xpack.enterpriseSearch.content.engines.searchBar.ariaLabel', {
defaultMessage: 'Search engines',
})}
fullWidth
onChange={(event) => {
setSearchValue(event.currentTarget.value);
}}
/>
</div>
<EuiSpacer size="s" />
<EuiText color="subdued" size="s">
{i18n.translate('xpack.enterpriseSearch.content.engines.searchPlaceholder.description', {
defaultMessage: 'Locate an engine via name or indices',
})}
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s">
<FormattedMessage
id="xpack.enterpriseSearch.content.engines.enginesList.description"
defaultMessage="Showing {currentPage}-{size} of {total}"
values={{
currentPage: (
<strong>
<FormattedNumber value={meta.from} />
</strong>
),
size: (
<strong>
<FormattedNumber value={meta.size} />
</strong>
),
total: <FormattedNumber value={meta.total} />,
}}
/>
</EuiText>
<DataPanel
title={
<h2>
{i18n.translate('xpack.enterpriseSearch.content.engines.title', {
defaultMessage: 'Engines',
})}
</h2>
}
>
<EnginesListTable
enginesList={results}
meta={meta}
onChange={handlePageChange(onPaginate)}
loading={false}
/>
</DataPanel>
<EuiSpacer size="xxl" />
<div />
</EnterpriseSearchContentPageTemplate>
);

View file

@ -0,0 +1,82 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { Actions } from '../../../shared/api_logic/create_api_logic';
import { flashAPIErrors } from '../../../shared/flash_messages';
import {
EnginesListAPIArguments,
EnginesListAPIResponse,
FetchEnginesAPILogic,
} from '../../api/engines/fetch_engines_api_logic';
import { DEFAULT_META, EngineListDetails, Meta, updateMetaPageIndex } from './types';
type EnginesListActions = Pick<
Actions<EnginesListAPIArguments, EnginesListAPIResponse>,
'apiError' | 'apiSuccess' | 'makeRequest'
> & {
fetchEngines({ meta, searchQuery }: { meta: Meta; searchQuery?: string }): {
meta: Meta;
searchQuery?: string;
};
onPaginate(pageNumber: number): { pageNumber: number };
};
interface EngineListValues {
data: typeof FetchEnginesAPILogic.values.data;
meta: Meta;
results: EngineListDetails[]; // stores engine list value from data
parameters: { meta: Meta; searchQuery?: string }; // Added this variable to store to the search Query value as well
status: typeof FetchEnginesAPILogic.values.status;
}
export const EnginesListLogic = kea<MakeLogicType<EngineListValues, EnginesListActions>>({
connect: {
actions: [FetchEnginesAPILogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [FetchEnginesAPILogic, ['data', 'status']],
},
path: ['enterprise_search', 'content', 'engine_list_logic'],
actions: {
fetchEngines: ({ meta, searchQuery }) => ({
meta,
searchQuery,
}),
onPaginate: (pageNumber) => ({ pageNumber }),
},
reducers: ({}) => ({
parameters: [
{ meta: DEFAULT_META },
{
apiSuccess: (_, { meta, searchQuery }) => ({
meta,
searchQuery,
}),
onPaginate: (state, { pageNumber }) => ({
...state,
meta: updateMetaPageIndex(state.meta, pageNumber),
}),
},
],
}),
selectors: ({ selectors }) => ({
results: [() => [selectors.data], (data) => data?.results ?? []],
meta: [() => [selectors.parameters], (parameters) => parameters.meta],
}),
listeners: ({ actions }) => ({
apiError: (e) => {
flashAPIErrors(e);
},
fetchEngines: async (input) => {
actions.makeRequest(input);
},
}),
});

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
export interface Meta {
from: number;
size: number;
total: number;
}
export interface EngineListDetails {
name: string;
indices: string[];
last_updated: string;
document_count: number;
}
export const DEFAULT_META = {
from: 1,
size: 3,
total: 0,
};
export const convertMetaToPagination = (meta: Meta) => ({
pageIndex: meta.from - 1,
pageSize: meta.size,
totalItemCount: meta.total,
});
export const updateMetaPageIndex = (oldState: Meta, newPageIndex: number) => {
return { ...oldState, from: newPageIndex };
};

View file

@ -23,5 +23,6 @@ export const SEARCH_INDEX_CRAWLER_DOMAIN_DETAIL_PATH = `${SEARCH_INDEX_PATH}/cra
export const SEARCH_INDEX_SELECT_CONNECTOR_PATH = `${SEARCH_INDEX_PATH}/select_connector`;
export const ENGINES_PATH = `${ROOT_PATH}engines`;
export const ENGINE_CREATION_PATH = `${ENGINES_PATH}/new`;
export const ML_MANAGE_TRAINED_MODELS_PATH = '/app/ml/trained_models';