[App Search] Migrate Create Meta Engine View (#92127)

* New empty MetaEngineCreation component

* Added MetaEngineCreation to AppSearchConfigured router

* Empty MetaEngineCreationLogic

* Add rawName value and setRawName action to MetaEngineCreationLogic

* Add indexedEngineNames value and setIndexedEngineNames action to MEtaEngineCreationLogic

* Add selectedIndexedEngineNames value and setIndexedEngineNames action to MetaEngineCreationLogic

* Add description to MetaEngineCreation

* Add name selector to MetaEngineCreationLogic

* Added MetaEngineCreationNameInput to MetaEngineCreation

* Add fetchIndexedEngineNames listener to MetaEngineCreationLogic

* Call fetchIndexedEngineNames when MetaEngineCreation first renders

* Add EuiComboBox for selectedEngineNames to MetaEngineCreation

* WIP Add meta engine source engine limit warning to MetaEngineCreation

* Add submitEngine listener to MetaEngineCreationLogic

* Add onEngineCreationSuccess to MetaEngineCreationLogic

* Fixing tests for MetaEngineCreationLogic

* Fix tests for MetaEngineCreation

* Add Create a meta engine button to EnginesOverview

* Use DEFAULT_META for fetching indexed engine names in MetaEngineCreationLogic

* Copy fixes

* Updating POST /api/engines tests

* Add noItemsMessage prop to EnginesTable

* Add empty prompt to Meta Engines table in EnginesOverview

* Apply suggestions from code review

Co-authored-by: Constance <constancecchen@users.noreply.github.com>

* Better form functionality in MetaEngineCreation

* Fix errors from github

* More MetaEngineCreation coverage

* Meta MetaEngineCreationLogic coverage

* Update x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts

Co-authored-by: Constance <constancecchen@users.noreply.github.com>

* Update x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts

Co-authored-by: Constance <constancecchen@users.noreply.github.com>

* Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts

Co-authored-by: Constance <constancecchen@users.noreply.github.com>

Co-authored-by: Constance <constancecchen@users.noreply.github.com>
This commit is contained in:
Byron Hulcher 2021-03-02 11:20:27 -05:00 committed by GitHub
parent 41b81a1011
commit 3d065739ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 978 additions and 27 deletions

View file

@ -22,3 +22,25 @@ export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate(
defaultMessage: 'Create an engine',
}
);
export const CREATE_A_META_ENGINE_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.engines.createAMetaEngineButton.ButtonLabel',
{
defaultMessage: 'Create a meta engine',
}
);
export const META_ENGINE_EMPTY_PROMPT_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPrompTitle',
{
defaultMessage: 'No meta engines yet',
}
);
export const META_ENGINE_EMPTY_PROMPT_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPromptDescription',
{
defaultMessage:
'Meta engines allow you to combine multiple engines into one searchable engine.',
}
);

View file

@ -12,6 +12,8 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiEmptyPrompt } from '@elastic/eui';
import { LoadingState, EmptyState } from './components';
import { EnginesTable } from './engines_table';
@ -19,7 +21,6 @@ import { EnginesOverview } from './';
describe('EnginesOverview', () => {
const values = {
hasPlatinumLicense: false,
dataLoading: false,
engines: [],
enginesMeta: {
@ -39,6 +40,7 @@ describe('EnginesOverview', () => {
},
},
metaEnginesLoading: false,
hasPlatinumLicense: false,
};
const actions = {
loadEngines: jest.fn(),
@ -73,7 +75,7 @@ describe('EnginesOverview', () => {
const valuesWithEngines = {
...values,
dataLoading: false,
engines: ['dummy-engine'],
engines: ['test-engine'],
enginesMeta: {
page: {
current: 1,
@ -84,6 +86,7 @@ describe('EnginesOverview', () => {
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(valuesWithEngines);
});
@ -102,18 +105,47 @@ describe('EnginesOverview', () => {
).toEqual('/engine_creation');
});
describe('when on a platinum license', () => {
it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => {
describe('when user has a platinum license', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
setMockValues({
...valuesWithEngines,
hasPlatinumLicense: true,
metaEngines: ['dummy-meta-engine'],
});
const wrapper = shallow(<EnginesOverview />);
wrapper = shallow(<EnginesOverview />);
});
it('renders a 2nd meta engines table ', async () => {
expect(wrapper.find(EnginesTable)).toHaveLength(2);
});
it('makes a 2nd meta engines call', () => {
expect(actions.loadMetaEngines).toHaveBeenCalled();
});
it('renders a create engine button which takes users to the create meta engine page', () => {
expect(
wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to')
).toEqual('/meta_engine_creation');
});
it('contains an EuiEmptyPrompt that takes users to the create meta when metaEngines is empty', () => {
setMockValues({
...valuesWithEngines,
hasPlatinumLicense: true,
metaEngines: [],
});
wrapper = shallow(<EnginesOverview />);
const metaEnginesTable = wrapper.find(EnginesTable).last().dive();
const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive();
expect(
emptyPrompt
.find('[data-test-subj="appSearchMetaEnginesEmptyStateCreationButton"]')
.prop('to')
).toEqual('/meta_engine_creation');
});
});
describe('pagination', () => {
@ -150,7 +182,7 @@ describe('EnginesOverview', () => {
setMockValues({
...valuesWithEngines,
hasPlatinumLicense: true,
metaEngines: ['dummy-meta-engine'],
metaEngines: ['test-meta-engine'],
});
const wrapper = shallow(<EnginesOverview />);
const pageEvent = { page: { index: 0 } };

View file

@ -16,6 +16,7 @@ import {
EuiPageContentBody,
EuiTitle,
EuiSpacer,
EuiEmptyPrompt,
} from '@elastic/eui';
import { FlashMessages } from '../../../shared/flash_messages';
@ -24,12 +25,19 @@ import { LicensingLogic } from '../../../shared/licensing';
import { EuiButtonTo } from '../../../shared/react_router_helpers';
import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { ENGINE_CREATION_PATH } from '../../routes';
import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes';
import { EngineIcon } from './assets/engine_icon';
import { MetaEngineIcon } from './assets/meta_engine_icon';
import { EnginesOverviewHeader, LoadingState, EmptyState } from './components';
import { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants';
import {
CREATE_AN_ENGINE_BUTTON_LABEL,
CREATE_A_META_ENGINE_BUTTON_LABEL,
ENGINES_TITLE,
META_ENGINE_EMPTY_PROMPT_DESCRIPTION,
META_ENGINE_EMPTY_PROMPT_TITLE,
META_ENGINES_TITLE,
} from './constants';
import { EnginesLogic } from './engines_logic';
import { EnginesTable } from './engines_table';
@ -37,6 +45,7 @@ import './engines_overview.scss';
export const EnginesOverview: React.FC = () => {
const { hasPlatinumLicense } = useValues(LicensingLogic);
const {
dataLoading,
engines,
@ -46,6 +55,7 @@ export const EnginesOverview: React.FC = () => {
metaEnginesMeta,
metaEnginesLoading,
} = useValues(EnginesLogic);
const { loadEngines, loadMetaEngines, onEnginesPagination, onMetaEnginesPagination } = useActions(
EnginesLogic
);
@ -100,15 +110,27 @@ export const EnginesOverview: React.FC = () => {
/>
</EuiPageContentBody>
{metaEngines.length > 0 && (
{hasPlatinumLicense && (
<>
<EuiSpacer size="xl" />
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
<MetaEngineIcon /> {META_ENGINES_TITLE}
</h2>
</EuiTitle>
<EuiPageContentHeaderSection>
<EuiTitle size="s">
<h2>
<MetaEngineIcon /> {META_ENGINES_TITLE}
</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiButtonTo
color="primary"
fill
data-test-subj="appSearchEnginesMetaEngineCreationButton"
to={META_ENGINE_CREATION_PATH}
>
{CREATE_A_META_ENGINE_BUTTON_LABEL}
</EuiButtonTo>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody data-test-subj="appSearchMetaEngines">
<EnginesTable
@ -118,6 +140,21 @@ export const EnginesOverview: React.FC = () => {
...convertMetaToPagination(metaEnginesMeta),
hidePerPageOptions: true,
}}
noItemsMessage={
<EuiEmptyPrompt
title={<h2>{META_ENGINE_EMPTY_PROMPT_TITLE}</h2>}
body={<p>{META_ENGINE_EMPTY_PROMPT_DESCRIPTION}</p>}
actions={
<EuiButtonTo
data-test-subj="appSearchMetaEnginesEmptyStateCreationButton"
fill
to={META_ENGINE_CREATION_PATH}
>
{CREATE_A_META_ENGINE_BUTTON_LABEL}
</EuiButtonTo>
}
/>
}
onChange={handlePageChange(onMetaEnginesPagination)}
/>
</EuiPageContentBody>

View file

@ -88,6 +88,13 @@ describe('EnginesTable', () => {
});
});
describe('noItemsMessage', () => {
it('passes the noItemsMessage prop', () => {
const wrapper = mountWithIntl(<EnginesTable {...props} noItemsMessage={'No items.'} />);
expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.');
});
});
describe('language field', () => {
it('renders language when available', () => {
const wrapper = mountWithIntl(

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import { useActions } from 'kea';
@ -24,6 +24,7 @@ import { EngineDetails } from '../engine/types';
interface EnginesTableProps {
items: EngineDetails[];
loading: boolean;
noItemsMessage?: ReactNode;
pagination: {
pageIndex: number;
pageSize: number;
@ -36,6 +37,7 @@ interface EnginesTableProps {
export const EnginesTable: React.FC<EnginesTableProps> = ({
items,
loading,
noItemsMessage,
pagination,
onChange,
}) => {
@ -148,6 +150,7 @@ export const EnginesTable: React.FC<EnginesTableProps> = ({
loading={loading}
pagination={pagination}
onChange={onChange}
noItemsMessage={noItemsMessage}
/>
);
};

View file

@ -0,0 +1,119 @@
/*
* 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 { EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DOCS_PREFIX } from '../../routes';
export const DEFAULT_LANGUAGE = 'Universal';
export const META_ENGINE_CREATION_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.title',
{
defaultMessage: 'Create a meta engine',
}
);
export const META_ENGINE_CREATION_FORM_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.title',
{
defaultMessage: 'Name your meta engine',
}
);
export const META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.submitButton.buttonLabel',
{
defaultMessage: 'Create meta engine',
}
);
export const META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.metaEngineDescription',
{
defaultMessage:
'Meta engines allow you to combine multiple engines into one searchable engine.',
}
);
export const META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.documentationLink',
{
defaultMessage: 'Read the documentation',
}
);
export const META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION = (
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.metaEngineCreation.form.documentationDescription"
defaultMessage="{documentationLink} for information about how to get started."
values={{
documentationLink: (
<EuiLink href={`${DOCS_PREFIX}/meta-engines-guide.html`} target="_blank">
{META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK}
</EuiLink>
),
}}
/>
);
export const META_ENGINE_CREATION_FORM_ENGINE_NAME_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.label',
{
defaultMessage: 'Meta engine name',
}
);
export const ALLOWED_CHARS_NOTE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.allowedCharactersHelpText',
{
defaultMessage: 'Meta engine names can only contain lowercase letters, numbers, and hyphens',
}
);
export const SANITIZED_NAME_NOTE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.sanitizedNameHelpText',
{
defaultMessage: 'Your meta engine will be named',
}
);
export const META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.placeholder',
{
defaultMessage: 'i.e., my-meta-engine',
}
);
export const META_ENGINE_CREATION_FORM_ENGINE_SOURCE_ENGINES_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.sourceEngines.label',
{
defaultMessage: 'Add source engines to this meta engine',
}
);
export const META_ENGINE_CREATION_FORM_MAX_SOURCE_ENGINES_WARNING_TITLE = (
maxEnginesPerMetaEngine: number
) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.sourceEngines.maxSourceEnginesWarningTitle',
{
defaultMessage: 'Meta engines have a limit of {maxEnginesPerMetaEngine} source engines',
values: { maxEnginesPerMetaEngine },
}
);
export const META_ENGINE_CREATION_SUCCESS_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngineCreation.successMessage',
{
defaultMessage: 'Successfully created meta engine.',
}
);

View file

@ -0,0 +1,8 @@
/*
* 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 { MetaEngineCreation } from './meta_engine_creation';

View file

@ -0,0 +1,187 @@
/*
* 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 '../../../__mocks__/shallow_useeffect.mock';
import { setMockActions, setMockValues } from '../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCallOut } from '@elastic/eui';
import { MetaEngineCreation } from './';
const DEFAULT_VALUES = {
// MetaEngineLogic
name: 'test-meta-engine',
rawName: 'test-meta-engine',
indexedEngineNames: [],
selectedIndexedEngineNames: ['one'],
// AppLogic
configuredLimits: { engine: { maxEnginesPerMetaEngine: 10 } },
};
const MOCK_ACTIONS = {
setRawName: jest.fn(),
setSelectedIndexedEngineNames: jest.fn(),
fetchIndexedEngineNames: jest.fn(),
submitEngine: jest.fn(),
};
describe('MetaEngineCreation', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues(DEFAULT_VALUES);
setMockActions(MOCK_ACTIONS);
});
it('renders and calls fetchIndexedEngineNames', () => {
const wrapper = shallow(<MetaEngineCreation />);
expect(wrapper.find('[data-test-subj="MetaEngineCreation"]')).toHaveLength(1);
expect(MOCK_ACTIONS.fetchIndexedEngineNames).toHaveBeenCalledTimes(1);
});
describe('MetaEngineCreationNameInput', () => {
it('uses rawName as its value', () => {
const wrapper = shallow(<MetaEngineCreation />);
expect(
wrapper
.find('[data-test-subj="MetaEngineCreationNameInput"]')
.render()
.find('input') // as far as I can tell I can't include this input in the .find() two lines above
.attr('value')
).toEqual('test-meta-engine');
});
it('EngineCreationForm calls submitEngine on form submit', () => {
const wrapper = shallow(<MetaEngineCreation />);
const simulatedEvent = {
preventDefault: jest.fn(),
};
wrapper.find('[data-test-subj="MetaEngineCreationForm"]').simulate('submit', simulatedEvent);
expect(MOCK_ACTIONS.submitEngine).toHaveBeenCalledTimes(1);
});
it('MetaEngineCreationNameInput calls setRawName on change', () => {
const wrapper = shallow(<MetaEngineCreation />);
const simulatedEvent = {
currentTarget: { value: 'new-raw-name' },
};
wrapper
.find('[data-test-subj="MetaEngineCreationNameInput"]')
.simulate('change', simulatedEvent);
expect(MOCK_ACTIONS.setRawName).toHaveBeenCalledWith('new-raw-name');
});
});
describe('EngineCreationNameFormRow', () => {
it('renders sanitized name helptext when the raw name is being sanitized', () => {
setMockValues({
...DEFAULT_VALUES,
name: 'name-with-special-characters',
rawName: 'Name__With#$&*%Special--Characters',
});
const wrapper = shallow(<MetaEngineCreation />);
const formRow = wrapper.find('[data-test-subj="MetaEngineCreationNameFormRow"]').dive();
expect(formRow.contains('Your meta engine will be named')).toBeTruthy();
});
it('renders allowed character helptext when rawName and sanitizedName match', () => {
setMockValues({
...DEFAULT_VALUES,
name: 'name-without-special-characters',
rawName: 'name-without-special-characters',
});
const wrapper = shallow(<MetaEngineCreation />);
const formRow = wrapper.find('[data-test-subj="MetaEngineCreationNameFormRow"]').dive();
expect(
formRow.contains(
'Meta engine names can only contain lowercase letters, numbers, and hyphens'
)
).toBeTruthy();
});
});
it('MetaEngineCreationSourceEnginesInput calls calls setSelectedIndexedEngines on change', () => {
const wrapper = shallow(<MetaEngineCreation />);
wrapper
.find('[data-test-subj="MetaEngineCreationSourceEnginesInput"]')
.simulate('change', [{ label: 'foo', value: 'foo' }]);
expect(MOCK_ACTIONS.setSelectedIndexedEngineNames).toHaveBeenCalledWith(['foo']);
});
it('renders a warning callout when user has selected too many engines', () => {
setMockValues({
...DEFAULT_VALUES,
...{
selectedIndexedEngineNames: ['one', 'two', 'three'],
configuredLimits: { engine: { maxEnginesPerMetaEngine: 2 } },
},
});
const wrapper = shallow(<MetaEngineCreation />);
expect(wrapper.find(EuiCallOut).prop('title')).toContain('Meta engines have a limit of');
});
describe('NewMetaEngineSubmitButton', () => {
it('is enabled for a valid submission', () => {
const wrapper = shallow(<MetaEngineCreation />);
const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]');
expect(submitButton.prop('disabled')).toEqual(false);
});
it('is disabled when name is empty', () => {
setMockValues({
...DEFAULT_VALUES,
...{
name: '',
rawName: '',
},
});
const wrapper = shallow(<MetaEngineCreation />);
const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]');
expect(submitButton.prop('disabled')).toEqual(true);
});
it('is disabled when user has selected no engines', () => {
setMockValues({
...DEFAULT_VALUES,
...{
selectedIndexedEngineNames: [],
},
});
const wrapper = shallow(<MetaEngineCreation />);
const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]');
expect(submitButton.prop('disabled')).toEqual(true);
});
it('is disabled when user has selected too many engines', () => {
setMockValues({
...DEFAULT_VALUES,
...{
selectedIndexedEngineNames: ['one', 'two', 'three'],
configuredLimits: { engine: { maxEnginesPerMetaEngine: 2 } },
},
});
const wrapper = shallow(<MetaEngineCreation />);
const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]');
expect(submitButton.prop('disabled')).toEqual(true);
});
});
});

View file

@ -0,0 +1,170 @@
/*
* 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, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiCallOut,
EuiComboBox,
EuiComboBoxOptionOption,
EuiForm,
EuiFlexGroup,
EuiFormRow,
EuiFlexItem,
EuiFieldText,
EuiPageContent,
EuiPageHeader,
EuiPageHeaderSection,
EuiSpacer,
EuiText,
EuiTitle,
EuiButton,
} from '@elastic/eui';
import { FlashMessages } from '../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { AppLogic } from '../../app_logic';
import {
ALLOWED_CHARS_NOTE,
META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION,
META_ENGINE_CREATION_FORM_ENGINE_NAME_LABEL,
META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER,
META_ENGINE_CREATION_FORM_ENGINE_SOURCE_ENGINES_LABEL,
META_ENGINE_CREATION_FORM_MAX_SOURCE_ENGINES_WARNING_TITLE,
META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION,
META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL,
META_ENGINE_CREATION_FORM_TITLE,
META_ENGINE_CREATION_TITLE,
SANITIZED_NAME_NOTE,
} from './constants';
import { MetaEngineCreationLogic } from './meta_engine_creation_logic';
const engineNameToComboBoxOption = (engineName: string): EuiComboBoxOptionOption<string> => ({
label: engineName,
});
const comboBoxOptionToEngineName = (option: EuiComboBoxOptionOption<string>): string =>
option.label;
export const MetaEngineCreation: React.FC = () => {
const {
configuredLimits: {
engine: { maxEnginesPerMetaEngine } = { maxEnginesPerMetaEngine: Infinity },
},
} = useValues(AppLogic);
const {
fetchIndexedEngineNames,
setRawName,
setSelectedIndexedEngineNames,
submitEngine,
} = useActions(MetaEngineCreationLogic);
const { rawName, name, indexedEngineNames, selectedIndexedEngineNames } = useValues(
MetaEngineCreationLogic
);
useEffect(() => {
fetchIndexedEngineNames();
}, []);
return (
<div data-test-subj="MetaEngineCreation">
<SetPageChrome trail={[META_ENGINE_CREATION_TITLE]} />
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>{META_ENGINE_CREATION_TITLE}</h1>
</EuiTitle>
<EuiText>{META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION}</EuiText>
<EuiText>{META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION}</EuiText>
</EuiPageHeaderSection>
</EuiPageHeader>
<FlashMessages />
<EuiPageContent>
<EuiForm
component="form"
data-test-subj="MetaEngineCreationForm"
onSubmit={(e) => {
e.preventDefault();
submitEngine();
}}
>
<EuiTitle>
<EuiText>{META_ENGINE_CREATION_FORM_TITLE}</EuiText>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
data-test-subj="MetaEngineCreationNameFormRow"
label={META_ENGINE_CREATION_FORM_ENGINE_NAME_LABEL}
helpText={
name.length > 0 && rawName !== name ? (
<>
{SANITIZED_NAME_NOTE} <strong>{name}</strong>
</>
) : (
ALLOWED_CHARS_NOTE
)
}
fullWidth
>
<EuiFieldText
name="engine-name"
value={rawName}
onChange={(event) => setRawName(event.currentTarget.value)}
fullWidth
data-test-subj="MetaEngineCreationNameInput"
placeholder={META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER}
autoFocus
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFormRow label={META_ENGINE_CREATION_FORM_ENGINE_SOURCE_ENGINES_LABEL} fullWidth>
<EuiComboBox
data-test-subj="MetaEngineCreationSourceEnginesInput"
options={indexedEngineNames.map(engineNameToComboBoxOption)}
selectedOptions={selectedIndexedEngineNames.map(engineNameToComboBoxOption)}
onChange={(options) => {
setSelectedIndexedEngineNames(options.map(comboBoxOptionToEngineName));
}}
/>
</EuiFormRow>
<EuiSpacer />
{selectedIndexedEngineNames.length > maxEnginesPerMetaEngine && (
<EuiCallOut
color="warning"
title={META_ENGINE_CREATION_FORM_MAX_SOURCE_ENGINES_WARNING_TITLE(
maxEnginesPerMetaEngine
)}
/>
)}
<EuiSpacer />
<EuiButton
disabled={
name.length === 0 ||
selectedIndexedEngineNames.length === 0 ||
selectedIndexedEngineNames.length > maxEnginesPerMetaEngine
}
type="submit"
data-test-subj="NewMetaEngineSubmitButton"
fill
color="secondary"
>
{META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL}
</EuiButton>
</EuiForm>
</EuiPageContent>
</div>
);
};

View file

@ -0,0 +1,175 @@
/*
* 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,
mockHttpValues,
mockFlashMessageHelpers,
mockKibanaValues,
} from '../../../__mocks__';
import { nextTick } from '@kbn/test/jest';
import { MetaEngineCreationLogic } from './meta_engine_creation_logic';
describe('MetaEngineCreationLogic', () => {
const { mount } = new LogicMounter(MetaEngineCreationLogic);
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
indexedEngineNames: [],
name: '',
rawName: '',
selectedIndexedEngineNames: [],
};
it('has expected default values', () => {
mount();
expect(MetaEngineCreationLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('setRawName', () => {
beforeAll(() => {
jest.clearAllMocks();
mount();
MetaEngineCreationLogic.actions.setRawName('Name__With#$&*%Special--Characters');
});
it('should set rawName to provided value', () => {
expect(MetaEngineCreationLogic.values.rawName).toEqual(
'Name__With#$&*%Special--Characters'
);
});
it('should set name to a sanitized value', () => {
expect(MetaEngineCreationLogic.values.name).toEqual('name-with-special-characters');
});
});
describe('setIndexedEngineNames', () => {
it('should set indexedEngineNames to provided value', () => {
mount();
MetaEngineCreationLogic.actions.setIndexedEngineNames(['first', 'middle', 'last']);
expect(MetaEngineCreationLogic.values.indexedEngineNames).toEqual([
'first',
'middle',
'last',
]);
});
});
describe('setSelectedIndexedEngineNames', () => {
it('should set selectedIndexedEngineNames to provided value', () => {
mount();
MetaEngineCreationLogic.actions.setSelectedIndexedEngineNames(['one', 'two', 'three']);
expect(MetaEngineCreationLogic.values.selectedIndexedEngineNames).toEqual([
'one',
'two',
'three',
]);
});
});
});
describe('listeners', () => {
describe('fetchIndexedEngineNames', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls flashAPIErrors on API Error', async () => {
http.get.mockReturnValueOnce(Promise.reject());
MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
});
it('calls onEngineCreationSuccess on valid submission', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'setIndexedEngineNames');
http.get.mockReturnValueOnce(
Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 1 } } })
);
MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
await nextTick();
expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).toHaveBeenCalledWith(['foo']);
});
it('if there are remaining pages it should call fetchIndexedEngineNames recursively with an incremented page', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'fetchIndexedEngineNames');
http.get.mockReturnValueOnce(
Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 2 } } })
);
MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
await nextTick();
expect(MetaEngineCreationLogic.actions.fetchIndexedEngineNames).toHaveBeenCalledWith(2);
});
it('if there are no remaining pages it should end without calling recursively', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'fetchIndexedEngineNames');
http.get.mockReturnValueOnce(
Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 1 } } })
);
MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
await nextTick();
expect(MetaEngineCreationLogic.actions.fetchIndexedEngineNames).toHaveBeenCalledTimes(1); // it's one time cause we called it two lines above
});
});
describe('onEngineCreationSuccess', () => {
beforeAll(() => {
jest.clearAllMocks();
mount({ language: 'English', rawName: 'test' });
MetaEngineCreationLogic.actions.onEngineCreationSuccess();
});
it('should set a success message', () => {
expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created meta engine.');
});
it('should navigate the user to the engine page', () => {
expect(navigateToUrl).toHaveBeenCalledWith('/engines/test');
});
});
describe('submitEngine', () => {
beforeAll(() => {
jest.clearAllMocks();
mount({ rawName: 'test', selectedIndexedEngineNames: ['foo'] });
});
it('POSTS to /api/app_search/engines', () => {
const body = JSON.stringify({
name: 'test',
type: 'meta',
source_engines: ['foo'],
});
MetaEngineCreationLogic.actions.submitEngine();
expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body });
});
it('calls onEngineCreationSuccess on valid submission', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'onEngineCreationSuccess');
http.post.mockReturnValueOnce(Promise.resolve({}));
MetaEngineCreationLogic.actions.submitEngine();
await nextTick();
expect(MetaEngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1);
});
it('calls flashAPIErrors on API Error', async () => {
jest.spyOn(MetaEngineCreationLogic.actions, 'setIndexedEngineNames');
http.post.mockReturnValueOnce(Promise.reject());
MetaEngineCreationLogic.actions.submitEngine();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).not.toHaveBeenCalled();
});
});
});
});

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 { generatePath } from 'react-router-dom';
import { kea, MakeLogicType } from 'kea';
import { Meta } from '../../../../../common/types';
import { DEFAULT_META } from '../../../shared/constants';
import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { KibanaLogic } from '../../../shared/kibana';
import { ENGINE_PATH } from '../../routes';
import { formatApiName } from '../../utils/format_api_name';
import { EngineDetails } from '../engine/types';
import { META_ENGINE_CREATION_SUCCESS_MESSAGE } from './constants';
interface MetaEngineCreationValues {
indexedEngineNames: string[];
name: string;
rawName: string;
selectedIndexedEngineNames: string[];
}
interface MetaEngineCreationActions {
fetchIndexedEngineNames(page?: number): { page: number };
onEngineCreationSuccess(): void;
setIndexedEngineNames(
indexedEngineNames: MetaEngineCreationValues['indexedEngineNames']
): { indexedEngineNames: MetaEngineCreationValues['indexedEngineNames'] };
setRawName(rawName: string): { rawName: string };
setSelectedIndexedEngineNames(
selectedIndexedEngineNames: MetaEngineCreationValues['selectedIndexedEngineNames']
): { selectedIndexedEngineNames: MetaEngineCreationValues['selectedIndexedEngineNames'] };
submitEngine(): void;
}
export const MetaEngineCreationLogic = kea<
MakeLogicType<MetaEngineCreationValues, MetaEngineCreationActions>
>({
path: ['enterprise_search', 'app_search', 'meta_engine_creation_logic'],
actions: {
fetchIndexedEngineNames: (page = DEFAULT_META.page.current) => ({ page }),
onEngineCreationSuccess: true,
setIndexedEngineNames: (indexedEngineNames) => ({ indexedEngineNames }),
setRawName: (rawName) => ({ rawName }),
setSelectedIndexedEngineNames: (selectedIndexedEngineNames) => ({ selectedIndexedEngineNames }),
submitEngine: () => null,
},
reducers: {
indexedEngineNames: [
[],
{
setIndexedEngineNames: (_, { indexedEngineNames }) => indexedEngineNames,
},
],
rawName: [
'',
{
setRawName: (_, { rawName }) => rawName,
},
],
selectedIndexedEngineNames: [
[],
{
setSelectedIndexedEngineNames: (_, { selectedIndexedEngineNames }) =>
selectedIndexedEngineNames,
},
],
},
selectors: ({ selectors }) => ({
name: [() => [selectors.rawName], (rawName: string) => formatApiName(rawName)],
}),
listeners: ({ values, actions }) => ({
fetchIndexedEngineNames: async ({ page }) => {
const { http } = HttpLogic.values;
let response: { results: EngineDetails[]; meta: Meta } | undefined;
try {
response = await http.get('/api/app_search/engines', {
query: { type: 'indexed', 'page[current]': page, 'page[size]': DEFAULT_META.page.size },
});
} catch (e) {
flashAPIErrors(e);
}
if (response) {
const engineNames = response.results.map((result) => result.name);
actions.setIndexedEngineNames([...values.indexedEngineNames, ...engineNames]);
if (page < response.meta.page.total_pages) {
actions.fetchIndexedEngineNames(page + 1);
}
}
},
onEngineCreationSuccess: () => {
const { name } = values;
const { navigateToUrl } = KibanaLogic.values;
const enginePath = generatePath(ENGINE_PATH, { engineName: name });
setQueuedSuccessMessage(META_ENGINE_CREATION_SUCCESS_MESSAGE);
navigateToUrl(enginePath);
},
submitEngine: async () => {
const { http } = HttpLogic.values;
const { name, selectedIndexedEngineNames } = values;
const body = JSON.stringify({
name,
type: 'meta',
source_engines: selectedIndexedEngineNames,
});
try {
await http.post('/api/app_search/engines', { body });
actions.onEngineCreationSuccess();
} catch (e) {
flashAPIErrors(e);
}
},
}),
});

View file

@ -21,6 +21,7 @@ import { EngineRouter } from './components/engine';
import { EngineCreation } from './components/engine_creation';
import { EnginesOverview } from './components/engines';
import { ErrorConnecting } from './components/error_connecting';
import { MetaEngineCreation } from './components/meta_engine_creation';
import { SetupGuide } from './components/setup_guide';
import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './';
@ -117,6 +118,22 @@ describe('AppSearchConfigured', () => {
expect(wrapper.find(EngineCreation)).toHaveLength(0);
});
});
describe('canManageMetaEngines', () => {
it('renders MetaEngineCreation when user canManageMetaEngines is true', () => {
setMockValues({ myRole: { canManageMetaEngines: true } });
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(MetaEngineCreation)).toHaveLength(1);
});
it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => {
setMockValues({ myRole: { canManageMetaEngines: false } });
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(MetaEngineCreation)).toHaveLength(0);
});
});
});
});

View file

@ -25,6 +25,7 @@ import { EngineCreation } from './components/engine_creation';
import { EnginesOverview, ENGINES_TITLE } from './components/engines';
import { ErrorConnecting } from './components/error_connecting';
import { Library } from './components/library';
import { MetaEngineCreation } from './components/meta_engine_creation';
import { ROLE_MAPPINGS_TITLE } from './components/role_mappings';
import { Settings, SETTINGS_TITLE } from './components/settings';
import { SetupGuide } from './components/setup_guide';
@ -38,6 +39,7 @@ import {
ENGINES_PATH,
ENGINE_PATH,
LIBRARY_PATH,
META_ENGINE_CREATION_PATH,
} from './routes';
export const AppSearch: React.FC<InitialAppData> = (props) => {
@ -60,7 +62,7 @@ export const AppSearchConfigured: React.FC<InitialAppData> = (props) => {
const { initializeAppData } = useActions(AppLogic);
const {
hasInitialized,
myRole: { canManageEngines },
myRole: { canManageEngines, canManageMetaEngines },
} = useValues(AppLogic);
const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
@ -106,6 +108,11 @@ export const AppSearchConfigured: React.FC<InitialAppData> = (props) => {
<EngineCreation />
</Route>
)}
{canManageMetaEngines && (
<Route exact path={META_ENGINE_CREATION_PATH}>
<MetaEngineCreation />
</Route>
)}
<Route>
<NotFound product={APP_SEARCH_PLUGIN} />
</Route>

View file

@ -40,6 +40,7 @@ export const ENGINE_REINDEX_JOB_PATH = `${ENGINE_PATH}/reindex-job/:activeReinde
export const ENGINE_CRAWLER_PATH = `${ENGINE_PATH}/crawler`;
// TODO: Crawler sub-pages
export const META_ENGINE_CREATION_PATH = '/meta_engine_creation';
export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`;
export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`;

View file

@ -122,19 +122,56 @@ describe('engine routes', () => {
});
describe('validates', () => {
it('correctly', () => {
const request = { body: { name: 'some-engine', language: 'en' } };
mockRouter.shouldValidate(request);
describe('indexed engines', () => {
it('correctly', () => {
const request = { body: { name: 'some-engine', language: 'en' } };
mockRouter.shouldValidate(request);
});
it('missing name', () => {
const request = { body: { language: 'en' } };
mockRouter.shouldThrow(request);
});
it('optional language', () => {
const request = { body: { name: 'some-engine' } };
mockRouter.shouldValidate(request);
});
});
it('missing name', () => {
const request = { body: { language: 'en' } };
mockRouter.shouldThrow(request);
});
describe('meta engines', () => {
it('all properties', () => {
const request = {
body: { name: 'some-meta-engine', type: 'any', language: 'en', source_engines: [] },
};
mockRouter.shouldValidate(request);
});
it('optional language', () => {
const request = { body: { name: 'some-engine' } };
mockRouter.shouldValidate(request);
it('missing name', () => {
const request = {
body: { type: 'any', language: 'en', source_engines: [] },
};
mockRouter.shouldThrow(request);
});
it('optional language', () => {
const request = {
body: { name: 'some-meta-engine', type: 'any', source_engines: [] },
};
mockRouter.shouldValidate(request);
});
it('optional source_engines', () => {
const request = {
body: { name: 'some-meta-engine', type: 'any', language: 'en' },
};
mockRouter.shouldValidate(request);
});
it('optional type', () => {
const request = { body: { name: 'some-engine' } };
mockRouter.shouldValidate(request);
});
});
});
});

View file

@ -45,6 +45,8 @@ export function registerEnginesRoutes({
body: schema.object({
name: schema.string(),
language: schema.maybe(schema.string()),
source_engines: schema.maybe(schema.arrayOf(schema.string())),
type: schema.maybe(schema.string()),
}),
},
},