[Enterprise Search][Engines] feat: create engine flow (#149263)

## Summary

Added the create engine flyout and associated logics

### Screenshots


https://user-images.githubusercontent.com/1972968/213552877-52f81594-bf0d-41bf-bf4b-ca16f922562d.mov


### Checklist

- [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
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] 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))
- [ ] 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))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Rodney Norris 2023-01-25 08:21:38 -06:00 committed by GitHub
parent 2589e34f3e
commit 32b1e9b1ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 796 additions and 35 deletions

View file

@ -0,0 +1,32 @@
/*
* 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 { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { createEngine } from './create_engine_api_logic';
describe('CreateEngineApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('createEngine', () => {
it('calls correct api', async () => {
const engine = { engineName: 'my-engine', indices: ['an-index'] };
const promise = Promise.resolve(engine);
http.post.mockReturnValue(promise);
const result = createEngine(engine);
await nextTick();
expect(http.post).toHaveBeenCalledWith('/internal/enterprise_search/engines', {
body: '{"indices":["an-index"],"name":"my-engine"}',
});
await expect(result).resolves.toEqual(engine);
});
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { EnterpriseSearchEngine } from '../../../../../common/types/engines';
import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface CreateEngineApiParams {
engineName: string;
indices: string[];
}
export type CreateEngineApiResponse = EnterpriseSearchEngine;
export type CreateEngineApiLogicActions = Actions<CreateEngineApiParams, CreateEngineApiResponse>;
export const createEngine = async ({
engineName,
indices,
}: CreateEngineApiParams): Promise<CreateEngineApiResponse> => {
const route = `/internal/enterprise_search/engines`;
return await HttpLogic.values.http.post<EnterpriseSearchEngine>(route, {
body: JSON.stringify({ indices, name: engineName }),
});
};
export const CreateEngineApiLogic = createApiLogic(['create_engine_api_logic'], createEngine, {
showErrorFlash: false,
});

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 { Meta } from '../../../../../common/types';
import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/indices';
import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { INPUT_THROTTLE_DELAY_MS } from '../../../shared/constants/timers';
import { HttpLogic } from '../../../shared/http';
export interface EnginesFetchIndicesApiParams {
searchQuery?: string;
}
export interface EnginesFetchIndicesApiResponse {
indices: ElasticsearchIndexWithIngestion[];
meta: Meta;
searchQuery?: string;
}
const INDEX_SEARCH_PAGE_SIZE = 40;
export const fetchIndices = async ({
searchQuery,
}: EnginesFetchIndicesApiParams): Promise<EnginesFetchIndicesApiResponse> => {
const { http } = HttpLogic.values;
const route = '/internal/enterprise_search/indices';
const query = {
page: 1,
return_hidden_indices: false,
search_query: searchQuery || null,
size: INDEX_SEARCH_PAGE_SIZE,
};
const response = await http.get<{ indices: ElasticsearchIndexWithIngestion[]; meta: Meta }>(
route,
{
query,
}
);
return { ...response, searchQuery };
};
export const FetchIndicesForEnginesAPILogic = createApiLogic(
['content', 'engines_fetch_indices_api_logic'],
fetchIndices,
{
requestBreakpointMS: INPUT_THROTTLE_DELAY_MS,
}
);
export type FetchIndicesForEnginesAPILogicActions = Actions<
EnginesFetchIndicesApiParams,
EnginesFetchIndicesApiResponse
>;

View file

@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n';
import { EnterpriseSearchEngineIndex } from '../../../../../common/types/engines';
import { CANCEL_BUTTON_LABEL } from '../../../shared/constants';
import { healthColorsMap } from '../../../shared/constants/health_colors';
import { indexHealthToHealthColor } from '../../../shared/constants/health_colors';
import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { EuiLinkTo } from '../../../shared/react_router_helpers';
@ -68,7 +68,7 @@ export const EngineIndices: React.FC = () => {
}),
render: (health: 'red' | 'green' | 'yellow' | 'unavailable') => (
<span>
<EuiIcon type="dot" color={healthColorsMap[health] ?? ''} />
<EuiIcon type="dot" color={indexHealthToHealthColor(health)} />
&nbsp;{health ?? '-'}
</span>
),

View file

@ -0,0 +1,89 @@
/*
* 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, useEffect } from 'react';
import { useValues, useActions } from 'kea';
import {
EuiComboBox,
EuiComboBoxProps,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiHighlight,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedNumber } from '@kbn/i18n-react';
import { Status } from '../../../../../../common/types/api';
import { ElasticsearchIndexWithIngestion } from '../../../../../../common/types/indices';
import { indexHealthToHealthColor } from '../../../../shared/constants/health_colors';
import { FetchIndicesForEnginesAPILogic } from '../../../api/engines/fetch_indices_api_logic';
export type IndicesSelectComboBoxProps = Omit<
EuiComboBoxProps<ElasticsearchIndexWithIngestion>,
'onCreateOption' | 'onSearchChange' | 'noSuggestions' | 'async'
> & {
'data-telemetry-id'?: string;
};
export const IndicesSelectComboBox = (props: IndicesSelectComboBoxProps) => {
const [searchQuery, setSearchQuery] = useState<string | undefined>(undefined);
const { makeRequest } = useActions(FetchIndicesForEnginesAPILogic);
const { status, data } = useValues(FetchIndicesForEnginesAPILogic);
useEffect(() => {
makeRequest({ searchQuery });
}, [searchQuery]);
const options: Array<EuiComboBoxOptionOption<ElasticsearchIndexWithIngestion>> =
data?.indices?.map(indexToOption) ?? [];
const renderOption = (option: EuiComboBoxOptionOption<ElasticsearchIndexWithIngestion>) => (
<EuiFlexGroup>
<EuiFlexItem>
<EuiHealth color={indexHealthToHealthColor(option.value?.health)}>
<EuiHighlight search={searchQuery ?? ''}>{option.value?.name ?? ''}</EuiHighlight>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>
<strong>
{i18n.translate('xpack.enterpriseSearch.content.engine.indicesSelect.docsLabel', {
defaultMessage: 'Docs:',
})}
</strong>
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FormattedNumber value={option.value?.count ?? 0} />
</EuiFlexItem>
</EuiFlexGroup>
);
const defaultedProps: EuiComboBoxProps<ElasticsearchIndexWithIngestion> = {
isLoading: status === Status.LOADING,
onSearchChange: (searchValue?: string) => {
setSearchQuery(searchValue);
},
options,
renderOption,
...props,
};
return <EuiComboBox async {...defaultedProps} />;
};
export const indexToOption = (
index: ElasticsearchIndexWithIngestion
): EuiComboBoxOptionOption<ElasticsearchIndexWithIngestion> => ({
label: index.name,
value: index,
});

View file

@ -0,0 +1,191 @@
/*
* 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 { useActions, useValues } from 'kea';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFieldText,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiLink,
EuiSpacer,
EuiSteps,
EuiText,
EuiTitle,
EuiComboBoxOptionOption,
EuiCallOut,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { Status } from '../../../../../common/types/api';
import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/indices';
import { isNotNullish } from '../../../../../common/utils/is_not_nullish';
import { CANCEL_BUTTON_LABEL } from '../../../shared/constants';
import { getErrorsFromHttpResponse } from '../../../shared/flash_messages/handle_api_errors';
import { indexToOption, IndicesSelectComboBox } from './components/indices_select_combobox';
import { CreateEngineLogic } from './create_engine_logic';
export interface CreateEngineFlyoutProps {
onClose: () => void;
}
export const CreateEngineFlyout = ({ onClose }: CreateEngineFlyoutProps) => {
const { createEngine, setEngineName, setSelectedIndices } = useActions(CreateEngineLogic);
const {
createDisabled,
createEngineError,
createEngineStatus,
engineName,
engineNameStatus,
formDisabled,
indicesStatus,
selectedIndices,
} = useValues(CreateEngineLogic);
const onIndicesChange = (
selectedOptions: Array<EuiComboBoxOptionOption<ElasticsearchIndexWithIngestion>>
) => {
setSelectedIndices(selectedOptions.map((option) => option.value).filter(isNotNullish));
};
return (
<EuiFlyout onClose={onClose} size="m">
<EuiFlyoutHeader>
<EuiTitle size="m">
<h3>
{i18n.translate('xpack.enterpriseSearch.content.engines.createEngine.headerTitle', {
defaultMessage: 'Create an engine',
})}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.enterpriseSearch.content.engines.createEngine.headerSubTitle"
defaultMessage="An engine allows your users to query data in your indices. Explore our {enginesDocsLink} to learn more!"
values={{
enginesDocsLink: (
<EuiLink
href="#" // TODO: replace with docs link
target="_blank"
data-telemetry-id="entSearchContent-engines-createEngine-docsLink"
external
>
{i18n.translate(
'xpack.enterpriseSearch.content.engines.createEngine.header.docsLink',
{ defaultMessage: 'Engines documentation' }
)}
</EuiLink>
),
}}
/>
</p>
</EuiText>
{createEngineStatus === Status.ERROR && createEngineError && (
<>
<EuiSpacer />
<EuiCallOut
color="danger"
title={i18n.translate(
'xpack.enterpriseSearch.content.engines.createEngine.header.createError.title',
{ defaultMessage: 'Error creating engine' }
)}
>
{getErrorsFromHttpResponse(createEngineError).map((errMessage, i) => (
<p id={`createErrorMsg.${i}`}>{errMessage}</p>
))}
</EuiCallOut>
</>
)}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiSteps
steps={[
{
children: (
<IndicesSelectComboBox
fullWidth
isDisabled={formDisabled}
onChange={onIndicesChange}
selectedOptions={selectedIndices.map(indexToOption)}
/>
),
status: indicesStatus,
title: i18n.translate(
'xpack.enterpriseSearch.content.engines.createEngine.selectIndices.title',
{ defaultMessage: 'Select indices' }
),
},
{
children: (
<EuiFieldText
fullWidth
disabled={formDisabled}
placeholder={i18n.translate(
'xpack.enterpriseSearch.content.engines.createEngine.nameEngine.placeholder',
{ defaultMessage: 'Engine name' }
)}
value={engineName}
onChange={(e) => setEngineName(e.target.value)}
/>
),
status: engineNameStatus,
title: i18n.translate(
'xpack.enterpriseSearch.content.engines.createEngine.nameEngine.title',
{ defaultMessage: 'Name your engine' }
),
},
]}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
disabled={formDisabled}
data-telemetry-id="entSearchContent-engines-createEngine-cancel"
onClick={onClose}
>
{CANCEL_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
<EuiButton
disabled={createDisabled || formDisabled}
data-telemetry-id="entSearchContent-engines-createEngine-submit"
fill
iconType="plusInCircle"
onClick={() => {
createEngine();
}}
>
{i18n.translate('xpack.enterpriseSearch.content.engines.createEngine.submit', {
defaultMessage: 'Create this engine',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,127 @@
/*
* 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 } from '../../../__mocks__/kea_logic';
import { Status } from '../../../../../common/types/api';
import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/indices';
import { CreateEngineApiLogic } from '../../api/engines/create_engine_api_logic';
import { CreateEngineLogic, CreateEngineLogicValues } from './create_engine_logic';
const DEFAULT_VALUES: CreateEngineLogicValues = {
createDisabled: true,
createEngineError: undefined,
createEngineStatus: Status.IDLE,
engineName: '',
engineNameStatus: 'incomplete',
formDisabled: false,
indicesStatus: 'incomplete',
selectedIndices: [],
};
const VALID_ENGINE_NAME = 'unit-test-001';
const INVALID_ENGINE_NAME = 'TEST';
const VALID_INDICES_DATA = [{ name: 'search-index-01' }] as ElasticsearchIndexWithIngestion[];
describe('CreateEngineLogic', () => {
const { mount: apiLogicMount } = new LogicMounter(CreateEngineApiLogic);
const { mount } = new LogicMounter(CreateEngineLogic);
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
apiLogicMount();
mount();
});
it('has expected defaults', () => {
expect(CreateEngineLogic.values).toEqual(DEFAULT_VALUES);
});
describe('listeners', () => {
it('createEngine makes expected request action', () => {
jest.spyOn(CreateEngineLogic.actions, 'createEngineRequest');
CreateEngineLogic.actions.setEngineName(VALID_ENGINE_NAME);
CreateEngineLogic.actions.setSelectedIndices(VALID_INDICES_DATA);
CreateEngineLogic.actions.createEngine();
expect(CreateEngineLogic.actions.createEngineRequest).toHaveBeenCalledTimes(1);
expect(CreateEngineLogic.actions.createEngineRequest).toHaveBeenCalledWith({
engineName: VALID_ENGINE_NAME,
indices: ['search-index-01'],
});
});
it('engineCreated is handled', () => {
jest.spyOn(CreateEngineLogic.actions, 'fetchEngines');
jest.spyOn(CreateEngineLogic.actions, 'closeEngineCreate');
CreateEngineApiLogic.actions.apiSuccess({
created: '',
indices: ['search-index-001'],
name: 'unit-test',
updated: '',
});
expect(CreateEngineLogic.actions.fetchEngines).toHaveBeenCalledTimes(1);
expect(CreateEngineLogic.actions.closeEngineCreate).toHaveBeenCalledTimes(1);
});
});
describe('selectors', () => {
describe('engineNameStatus', () => {
it('returns incomplete with empty engine name', () => {
expect(CreateEngineLogic.values.engineNameStatus).toEqual('incomplete');
});
it('returns complete with valid engine name', () => {
CreateEngineLogic.actions.setEngineName(VALID_ENGINE_NAME);
expect(CreateEngineLogic.values.engineNameStatus).toEqual('complete');
});
it('returns warning for invalid engine name', () => {
CreateEngineLogic.actions.setEngineName(INVALID_ENGINE_NAME);
expect(CreateEngineLogic.values.engineNameStatus).toEqual('warning');
});
});
describe('indicesStatus', () => {
it('returns incomplete with 0 indices', () => {
expect(CreateEngineLogic.values.indicesStatus).toEqual('incomplete');
});
it('returns complete with at least one index', () => {
CreateEngineLogic.actions.setSelectedIndices(VALID_INDICES_DATA);
expect(CreateEngineLogic.values.indicesStatus).toEqual('complete');
});
});
describe('createDisabled', () => {
it('false with valid data', () => {
CreateEngineLogic.actions.setSelectedIndices(VALID_INDICES_DATA);
CreateEngineLogic.actions.setEngineName(VALID_ENGINE_NAME);
expect(CreateEngineLogic.values.createDisabled).toEqual(false);
});
it('true with invalid data', () => {
CreateEngineLogic.actions.setSelectedIndices(VALID_INDICES_DATA);
CreateEngineLogic.actions.setEngineName(INVALID_ENGINE_NAME);
expect(CreateEngineLogic.values.createDisabled).toEqual(true);
});
});
describe('formDisabled', () => {
it('returns true while create request in progress', () => {
CreateEngineApiLogic.actions.makeRequest({
engineName: VALID_ENGINE_NAME,
indices: [VALID_INDICES_DATA[0].name],
});
expect(CreateEngineLogic.values.formDisabled).toEqual(true);
});
});
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 { Status } from '../../../../../common/types/api';
import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/indices';
import {
CreateEngineApiLogic,
CreateEngineApiLogicActions,
} from '../../api/engines/create_engine_api_logic';
import { EnginesListLogic } from './engines_list_logic';
const NAME_VALIDATION = new RegExp(/^[a-z0-9\-]+$/);
export interface CreateEngineLogicActions {
closeEngineCreate: () => void;
createEngine: () => void;
createEngineRequest: CreateEngineApiLogicActions['makeRequest'];
engineCreateError: CreateEngineApiLogicActions['apiError'];
engineCreated: CreateEngineApiLogicActions['apiSuccess'];
fetchEngines: () => void;
setEngineName: (engineName: string) => { engineName: string };
setSelectedIndices: (indices: ElasticsearchIndexWithIngestion[]) => {
indices: ElasticsearchIndexWithIngestion[];
};
}
export interface CreateEngineLogicValues {
createDisabled: boolean;
createEngineError?: typeof CreateEngineApiLogic.values.error;
createEngineStatus: typeof CreateEngineApiLogic.values.status;
engineName: string;
engineNameStatus: 'complete' | 'incomplete' | 'warning';
formDisabled: boolean;
indicesStatus: 'complete' | 'incomplete';
selectedIndices: ElasticsearchIndexWithIngestion[];
}
export const CreateEngineLogic = kea<
MakeLogicType<CreateEngineLogicValues, CreateEngineLogicActions>
>({
actions: {
createEngine: true,
setEngineName: (engineName: string) => ({ engineName }),
setSelectedIndices: (indices: ElasticsearchIndexWithIngestion[]) => ({ indices }),
},
connect: {
actions: [
EnginesListLogic,
['closeEngineCreate', 'fetchEngines'],
CreateEngineApiLogic,
[
'makeRequest as createEngineRequest',
'apiSuccess as engineCreated',
'apiError as engineCreateError',
],
],
values: [CreateEngineApiLogic, ['status as createEngineStatus', 'error as createEngineError']],
},
listeners: ({ actions, values }) => ({
createEngine: () => {
actions.createEngineRequest({
engineName: values.engineName,
indices: values.selectedIndices.map((index) => index.name),
});
},
engineCreated: () => {
actions.fetchEngines();
actions.closeEngineCreate();
},
}),
path: ['enterprise_search', 'content', 'create_engine_logic'],
reducers: {
engineName: [
'',
{
setEngineName: (_, { engineName }) => engineName,
},
],
selectedIndices: [
[],
{
setSelectedIndices: (_, { indices }) => indices,
},
],
},
selectors: ({ selectors }) => ({
createDisabled: [
() => [selectors.indicesStatus, selectors.engineNameStatus],
(
indicesStatus: CreateEngineLogicValues['indicesStatus'],
engineNameStatus: CreateEngineLogicValues['engineNameStatus']
) => indicesStatus !== 'complete' || engineNameStatus !== 'complete',
],
engineNameStatus: [
() => [selectors.engineName],
(engineName: string) => {
if (engineName.length === 0) return 'incomplete';
if (NAME_VALIDATION.test(engineName)) return 'complete';
return 'warning';
},
],
formDisabled: [
() => [selectors.createEngineStatus],
(createEngineStatus: CreateEngineLogicValues['createEngineStatus']) =>
createEngineStatus === Status.LOADING,
],
indicesStatus: [
() => [selectors.selectedIndices],
(selectedIndices: CreateEngineLogicValues['selectedIndices']) =>
selectedIndices.length > 0 ? 'complete' : 'incomplete',
],
}),
});

View file

@ -18,6 +18,7 @@ import { EnginesListLogic } from './engines_list_logic';
import { DEFAULT_META } from './types';
const DEFAULT_VALUES = {
createEngineFlyoutOpen: false,
data: undefined,
deleteModalEngine: null,
deleteModalEngineName: '',
@ -28,6 +29,7 @@ const DEFAULT_VALUES = {
meta: DEFAULT_META,
parameters: { meta: DEFAULT_META },
results: [],
searchQuery: '',
status: Status.IDLE,
};
@ -196,15 +198,13 @@ describe('EnginesListLogic', () => {
EnginesListLogic.actions.deleteSuccess({ engineName: results[0].name });
expect(mockFlashMessageHelpers.flashSuccessToast).toHaveBeenCalledTimes(1);
expect(EnginesListLogic.actions.fetchEngines).toHaveBeenCalledWith(
EnginesListLogic.values.parameters
);
expect(EnginesListLogic.actions.fetchEngines).toHaveBeenCalledWith();
expect(EnginesListLogic.actions.closeDeleteEngineModal).toHaveBeenCalled();
});
it('call makeRequest on fetchEngines', async () => {
jest.useFakeTimers({ legacyFakeTimers: true });
EnginesListLogic.actions.makeRequest = jest.fn();
EnginesListLogic.actions.fetchEngines({ meta: DEFAULT_META });
EnginesListLogic.actions.fetchEngines();
await nextTick();
expect(EnginesListLogic.actions.makeRequest).toHaveBeenCalledWith({
meta: DEFAULT_META,

View file

@ -13,7 +13,7 @@ import { shallow } from 'enzyme';
import { Status } from '../../../../../common/types/api';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template';
import { EmptyEnginesPrompt } from './components/empty_engines_prompt';
import { EnginesListTable } from './components/tables/engines_table';
@ -57,7 +57,7 @@ describe('EnginesList', () => {
setMockActions(mockActions);
const wrapper = shallow(<EnginesList />);
const pageTemplate = wrapper.find(EnterpriseSearchContentPageTemplate);
const pageTemplate = wrapper.find(EnterpriseSearchEnginesPageTemplate);
expect(pageTemplate.prop('isLoading')).toEqual(true);
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import useThrottle from 'react-use/lib/useThrottle';
@ -18,21 +18,23 @@ import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { INPUT_THROTTLE_DELAY_MS } from '../../../shared/constants/timers';
import { DataPanel } from '../../../shared/data_panel/data_panel';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template';
import { EmptyEnginesPrompt } from './components/empty_engines_prompt';
import { EnginesListTable } from './components/tables/engines_table';
import { CreateEngineFlyout } from './create_engine_flyout';
import { DeleteEngineModal } from './delete_engine_modal';
import { EnginesListLogic } from './engines_list_logic';
const CreateButton: React.FC = () => {
const { openEngineCreate } = useActions(EnginesListLogic);
return (
<EuiButton
fill
iconType="plusInCircle"
data-test-subj="enterprise-search-content-engines-creation-button"
data-telemetry-id="entSearchContent-engines-list-createEngine"
href={'TODO'}
onClick={openEngineCreate}
>
{i18n.translate('xpack.enterpriseSearch.content.engines.createEngineButtonLabel', {
defaultMessage: 'Create engine',
@ -42,22 +44,22 @@ const CreateButton: React.FC = () => {
};
export const EnginesList: React.FC = () => {
const { fetchEngines, onPaginate, openDeleteEngineModal } = useActions(EnginesListLogic);
const { meta, results, isLoading } = useValues(EnginesListLogic);
const [searchQuery, setSearchValue] = useState('');
const { closeEngineCreate, fetchEngines, onPaginate, openDeleteEngineModal, setSearchQuery } =
useActions(EnginesListLogic);
const { isLoading, meta, results, createEngineFlyoutOpen, searchQuery } =
useValues(EnginesListLogic);
const throttledSearchQuery = useThrottle(searchQuery, INPUT_THROTTLE_DELAY_MS);
useEffect(() => {
fetchEngines({
meta,
searchQuery: throttledSearchQuery,
});
fetchEngines();
}, [meta.from, meta.size, throttledSearchQuery]);
return (
<>
<DeleteEngineModal />
<EnterpriseSearchContentPageTemplate
{createEngineFlyoutOpen && <CreateEngineFlyout onClose={closeEngineCreate} />}
<EnterpriseSearchEnginesPageTemplate
pageChrome={[
i18n.translate('xpack.enterpriseSearch.content.engines.breadcrumb', {
defaultMessage: 'Engines',
@ -89,7 +91,7 @@ export const EnginesList: React.FC = () => {
pageTitle: i18n.translate('xpack.enterpriseSearch.content.engines.title', {
defaultMessage: 'Engines',
}),
rightSideItems: [<CreateButton />],
rightSideItems: results.length ? [<CreateButton />] : [],
}}
pageViewTelemetry="Engines"
isLoading={isLoading}
@ -114,7 +116,7 @@ export const EnginesList: React.FC = () => {
)}
fullWidth
onChange={(event) => {
setSearchValue(event.currentTarget.value);
setSearchQuery(event.currentTarget.value);
}}
/>
</div>
@ -174,7 +176,7 @@ export const EnginesList: React.FC = () => {
<EuiSpacer size="xxl" />
<div />
</EnterpriseSearchContentPageTemplate>
</EnterpriseSearchEnginesPageTemplate>
</>
);
};

View file

@ -37,19 +37,20 @@ type EnginesListActions = Pick<
'apiError' | 'apiSuccess' | 'makeRequest'
> & {
closeDeleteEngineModal(): void;
closeEngineCreate(): void;
deleteEngine: DeleteEnginesApiLogicActions['makeRequest'];
deleteError: DeleteEnginesApiLogicActions['apiError'];
deleteSuccess: DeleteEnginesApiLogicActions['apiSuccess'];
fetchEngines({ meta, searchQuery }: { meta: Meta; searchQuery?: string }): {
meta: Meta;
searchQuery?: string;
};
fetchEngines(): void;
onPaginate(args: EuiBasicTableOnChange): { pageNumber: number };
openDeleteEngineModal: (engine: EnterpriseSearchEngine) => { engine: EnterpriseSearchEngine };
openEngineCreate(): void;
setSearchQuery(searchQuery: string): { searchQuery: string };
};
interface EngineListValues {
createEngineFlyoutOpen: boolean;
data: typeof FetchEnginesAPILogic.values.data;
deleteModalEngine: EnterpriseSearchEngine | null;
deleteModalEngineName: string;
@ -60,6 +61,7 @@ interface EngineListValues {
meta: Meta;
parameters: { meta: Meta; searchQuery?: string }; // Added this variable to store to the search Query value as well
results: EnterpriseSearchEngine[]; // stores engine list value from data
searchQuery: string;
status: typeof FetchEnginesAPILogic.values.status;
}
@ -80,15 +82,22 @@ export const EnginesListLogic = kea<MakeLogicType<EngineListValues, EnginesListA
},
actions: {
closeDeleteEngineModal: true,
fetchEngines: ({ meta, searchQuery }) => ({
meta,
searchQuery,
}),
closeEngineCreate: true,
fetchEngines: true,
onPaginate: (args: EuiBasicTableOnChange) => ({ pageNumber: args.page.index }),
openDeleteEngineModal: (engine) => ({ engine }),
openEngineCreate: true,
setSearchQuery: (searchQuery: string) => ({ searchQuery }),
},
path: ['enterprise_search', 'content', 'engine_list_logic'],
reducers: ({}) => ({
createEngineFlyoutOpen: [
false,
{
closeEngineCreate: () => false,
openEngineCreate: () => true,
},
],
deleteModalEngine: [
null,
{
@ -113,6 +122,16 @@ export const EnginesListLogic = kea<MakeLogicType<EngineListValues, EnginesListA
...state,
meta: updateMetaPageIndex(state.meta, pageNumber),
}),
setSearchQuery: (state, { searchQuery }) => ({
...state,
searchQuery: searchQuery ? searchQuery : undefined,
}),
},
],
searchQuery: [
'',
{
setSearchQuery: (_, { searchQuery }) => searchQuery,
},
],
}),
@ -132,10 +151,10 @@ export const EnginesListLogic = kea<MakeLogicType<EngineListValues, EnginesListA
listeners: ({ actions, values }) => ({
deleteSuccess: () => {
actions.closeDeleteEngineModal();
actions.fetchEngines(values.parameters);
actions.fetchEngines();
},
fetchEngines: async (input) => {
actions.makeRequest(input);
fetchEngines: async () => {
actions.makeRequest(values.parameters);
},
}),
});

View file

@ -26,6 +26,7 @@ export interface Actions<Args, Result> {
export interface CreateApiOptions<Result> {
clearFlashMessagesOnMakeRequest: boolean;
requestBreakpointMS?: number;
showErrorFlash: boolean;
showSuccessFlashFn?: (result: Result) => string;
}
@ -60,10 +61,13 @@ export const createApiLogic = <Result, Args>(
flashSuccessToast(options.showSuccessFlashFn(result));
}
},
makeRequest: async (args) => {
makeRequest: async (args, breakpoint) => {
if (options.clearFlashMessagesOnMakeRequest) {
clearFlashMessages();
}
if (options.requestBreakpointMS) {
await breakpoint(options.requestBreakpointMS);
}
try {
const result = await apiFunction(args);
actions.apiSuccess(result);

View file

@ -5,7 +5,12 @@
* 2.0.
*/
export const healthColorsMap = {
import { HealthStatus } from '@elastic/elasticsearch/lib/api/types';
import { IconColor } from '@elastic/eui';
type HealthStatusStrings = 'red' | 'green' | 'yellow' | 'unavailable';
export const healthColorsMap: Record<HealthStatusStrings, IconColor> = {
red: 'danger',
green: 'success',
yellow: 'warning',
@ -20,3 +25,8 @@ export const healthColorsMapSelectable = {
yellow: 'warning',
YELLOW: 'warning',
};
export const indexHealthToHealthColor = (health?: HealthStatus | 'unavailable'): IconColor => {
if (!health) return '';
return healthColorsMap[health.toLowerCase() as HealthStatusStrings] ?? '';
};

View file

@ -51,6 +51,66 @@ describe('engines routes', () => {
});
});
describe('POST /internal/enterprise_search/engines', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/internal/enterprise_search/engines',
});
registerEnginesRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/api/engines',
});
});
it('validates correctly with engine_name', () => {
const request = {
body: {
indices: ['search-unit-test'],
name: 'some-engine',
},
};
mockRouter.shouldValidate(request);
});
it('fails validation without body', () => {
const request = { params: {} };
mockRouter.shouldThrow(request);
});
it('fails validation without name', () => {
const request = {
body: {
indices: ['search-unit-test'],
},
};
mockRouter.shouldThrow(request);
});
it('fails validation without indices', () => {
const request = {
body: {
name: 'some-engine',
},
};
mockRouter.shouldThrow(request);
});
});
describe('GET /internal/enterprise_search/engines/{engine_name}', () => {
let mockRouter: MockRouter;

View file

@ -26,6 +26,19 @@ export function registerEnginesRoutes({
enterpriseSearchRequestHandler.createRequest({ path: '/api/engines' })
);
router.post(
{
path: '/internal/enterprise_search/engines',
validate: {
body: schema.object({
indices: schema.arrayOf(schema.string()),
name: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({ path: '/api/engines' })
);
router.get(
{
path: '/internal/enterprise_search/engines/{engine_name}',