mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
2589e34f3e
commit
32b1e9b1ab
16 changed files with 796 additions and 35 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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
|
||||
>;
|
|
@ -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)} />
|
||||
{health ?? '-'}
|
||||
</span>
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
],
|
||||
}),
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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] ?? '';
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue