[App Search] Allow user to manage source engines through Kibana UX (#98866)

* New bulk create route for meta engine source engines

* New delete route for meta engine source engines

* Add removeSourceEngine and onSourceEngineRemove to SourceEnginesLogicActions

* New SourceEnginesTable component

* Use new SourceEnginesTable component in SourceEngines view

* Added closeAddSourceEnginesModal and openAddSourceEnginesModal to SourceEnginesLogic

* New AddSourceEnginesModal component

* New AddSourceEnginesButton component

* Add AddSourceEnginesButton and AddSourceEnginesModal to SourceEngines view

* Allow user to select source engines to add

* Add addSourceEngines and onSourceEnginesAdd to SourceEnginesLogic

* Submit new source engines when user saves from inside AddSourceEnginesModal

* Fix failing tests

* fix i18n

* Fix imports

* Use body instead of query params for source engines bulk create endpoint

* Tests for SouceEnginesLogic actions setIndexedEngines and fetchIndexedEngines

* Re-enabling two skipped tests

* Feedback: move source engine APIs to own file

- We generally organize routes/logic etc. by view, and since this is its own view, it can get its own file

* Misc UI polish

Table:
- Add EuiPageContent bordered panel (matches Curations & API logs which is a table in a panel)
- Remove bolding on engine name (matches rest of Kibana UI)
- Remove responsive false (we do want responsive tables in Kibana)

Modal:
- Remove EuiOverlayMask - per recent EUI changes, this now comes baked in with EuiModal
- Change description text to subdued to match other modals (e.g. Curations queries) in Kibana

* Misc i18n/copy tweaks

Modal:
- Add combobox placeholder text
- i18n cancel/save buttons
- inline i18n and change title casing to sentence casing

* Table refactors

- DRY out table columns shared with the main engines tables (title & formatting change slightly from the standalone UI, but this is fine / we should prefer Kibana standardization moving forward)
- Actions column changes
  - Give it a name - axe will throw issues for table column missing headings
  - Do not make actions a conditional empty array - we should opt to remove the column totally if there is no content present, otherwise screen readers will read out blank cells unnecessarily
  - Switch to icons w/ description tooltips to match the other Kibana tables
- Remove unnecessary sorting props (we don't have sorting enabled on any columns)

Tests
- Add describe block for organization
- Add missing coverage for window confirm branch and canManageMetaEngineSourceEngines branch

* Modal test fixes

- Remove unnecessary type casting
- Remove commented out line
- Fix missing onChange function coverage

* Modal: move unmemoized array iterations to Kea selectors

- more performant: kea selectors are memoized
- cleaner/less logic in views
- easier to write unit tests for

+ rename setSelectedEngineNamesToAdd to onAddEnginesSelection
+ remove unused selectors test code

* Modal: Add isLoading UX to submit button + value renames

- isLoading prevents double clicks/dupe events, and also provides a responsive UX hint that something is happening

- Var renames: there's only one modal on the page, being extra specific with the name isn't really necessary. If we ever add more than one to this view it would probably make sense to split up the logic files or do something else. Verbose modal names/states shouldn't necessarily be the answer

* Source Engines view test fixes

- Remove unused mock values/actions
- Move constants to within main describe
- Remove unhappy vs happy path describes - there aren't enough of either scenario to warrant the distinction
- add page actions describe block and fix skipped/mounted test by shallow diving into EuiPageHeader

* [Misc] Single components/index.ts export

For easier group importing

* Move all copy consts/strings to their own i18n constants file

* Refactor recursive fetchEngines fn to shared util

+ update MetaEnginesTableLogic to use new helper/DRY out code
+ write unit tests for just that helper
+ simplify other previous logic checks to just check that the fn was called + add mock

* Tests cleanup

- Move consts into top of describe blocks to match rest of codebase
- Remove logic comments for files that are only sourcing 1 logic file
- Modal:
  - shallow is fairly cheap and it's easier / more consistent w/ other tests to start a new wrapper every test
- Logic:
  - Remove unnecessarily EnginesLogic mocks
  - Remove mount() in beforeEach - it doesn't save us that many extra lines / better to be more consistent when starting tests that mount with values vs not
  - mock clearing in beforeEach to match rest of codebase
  - describe blocks: split up actions vs listeners, move selectors between the two
  - actions: fix tests that are in a describe() but not an it() (incorrect syntax)
  - Reducer/value checks: check against entire values obj to check for regressions or untested reducers & be consistent rest of codebase
  - listeners - DRY out beforeEach of success vs error paths, combine some tests that are a bit repetitive vs just having multiple assertions
- Logic comments:
  - Remove unnecessary comments (if we're not setting a response, it seems clear we're not using it)
  - Add extra business logic context explanation as to why we call re-initialize the engine

Co-authored-by: Constance Chen <constance.chen.3@gmail.com>
This commit is contained in:
Byron Hulcher 2021-05-15 01:10:53 -04:00 committed by GitHub
parent bfe08d25c5
commit ea8c92b353
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1426 additions and 335 deletions

View file

@ -6,3 +6,4 @@
*/
export { mockEngineValues, mockEngineActions } from './engine_logic.mock';
export { mockRecursivelyFetchEngines, mockSourceEngines } from './recursively_fetch_engines.mock';

View file

@ -0,0 +1,21 @@
/*
* 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 { EngineDetails } from '../components/engine/types';
export const mockSourceEngines = [
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[];
export const mockRecursivelyFetchEngines = jest.fn(({ onComplete }) =>
onComplete(mockSourceEngines)
);
jest.mock('../utils/recursively_fetch_engines', () => ({
recursivelyFetchEngines: mockRecursivelyFetchEngines,
}));

View file

@ -5,15 +5,16 @@
* 2.0.
*/
import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__';
import { nextTick } from '@kbn/test/jest';
import { LogicMounter } from '../../../../../__mocks__';
import { mockRecursivelyFetchEngines } from '../../../../__mocks__/recursively_fetch_engines.mock';
import { EngineDetails } from '../../../engine/types';
import { MetaEnginesTableLogic } from './meta_engines_table_logic';
describe('MetaEnginesTableLogic', () => {
const { mount } = new LogicMounter(MetaEnginesTableLogic);
const DEFAULT_VALUES = {
expandedRows: {},
sourceEngines: {},
@ -44,15 +45,11 @@ describe('MetaEnginesTableLogic', () => {
metaEngines: [...SOURCE_ENGINES, ...META_ENGINES] as EngineDetails[],
};
const { http } = mockHttpValues;
const { mount } = new LogicMounter(MetaEnginesTableLogic);
const { flashAPIErrors } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', async () => {
it('has expected default values', () => {
mount({}, DEFAULT_PROPS);
expect(MetaEnginesTableLogic.values).toEqual(DEFAULT_VALUES);
});
@ -122,16 +119,6 @@ describe('MetaEnginesTableLogic', () => {
});
it('calls fetchSourceEngines when it needs to fetch data for the itemId', () => {
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 1,
},
},
results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
})
);
mount();
jest.spyOn(MetaEnginesTableLogic.actions, 'fetchSourceEngines');
@ -142,89 +129,23 @@ describe('MetaEnginesTableLogic', () => {
});
describe('fetchSourceEngines', () => {
it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => {
it('calls addSourceEngines and displayRow when it has retrieved all pages', () => {
mount();
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 1,
},
},
results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
})
);
jest.spyOn(MetaEnginesTableLogic.actions, 'displayRow');
jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines');
MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1');
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/test-engine-1/source_engines',
{
query: {
'page[current]': 1,
'page[size]': 25,
},
}
expect(mockRecursivelyFetchEngines).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/app_search/engines/test-engine-1/source_engines',
})
);
expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({
'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
});
expect(MetaEnginesTableLogic.actions.displayRow).toHaveBeenCalledWith('test-engine-1');
});
it('display a flash message on error', async () => {
http.get.mockReturnValueOnce(Promise.reject());
mount();
MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1');
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
});
it('recursively fetches a number of pages', async () => {
mount();
jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines');
// First page
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 2,
},
},
results: [{ name: 'source-engine-1' }],
})
);
// Second and final page
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 2,
},
},
results: [{ name: 'source-engine-2' }],
})
);
MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1');
await nextTick();
expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({
'test-engine-1': [
// First page
{ name: 'source-engine-1' },
// Second and final page
{ name: 'source-engine-2' },
],
});
});
});
});

View file

@ -7,10 +7,8 @@
import { kea, MakeLogicType } from 'kea';
import { flashAPIErrors } from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { recursivelyFetchEngines } from '../../../../utils/recursively_fetch_engines';
import { EngineDetails } from '../../../engine/types';
import { EnginesAPIResponse } from '../../types';
interface MetaEnginesTableValues {
expandedRows: { [id: string]: boolean };
@ -85,36 +83,13 @@ export const MetaEnginesTableLogic = kea<
}
},
fetchSourceEngines: ({ engineName }) => {
const { http } = HttpLogic.values;
let enginesAccumulator: EngineDetails[] = [];
const recursiveFetchSourceEngines = async (page = 1) => {
try {
const { meta, results }: EnginesAPIResponse = await http.get(
`/api/app_search/engines/${engineName}/source_engines`,
{
query: {
'page[current]': page,
'page[size]': 25,
},
}
);
enginesAccumulator = [...enginesAccumulator, ...results];
if (page >= meta.page.total_pages) {
actions.addSourceEngines({ [engineName]: enginesAccumulator });
actions.displayRow(engineName);
} else {
recursiveFetchSourceEngines(page + 1);
}
} catch (e) {
flashAPIErrors(e);
}
};
recursiveFetchSourceEngines();
recursivelyFetchEngines({
endpoint: `/api/app_search/engines/${engineName}/source_engines`,
onComplete: (sourceEngines) => {
actions.addSourceEngines({ [engineName]: sourceEngines });
actions.displayRow(engineName);
},
});
},
}),
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { AddSourceEnginesButton } from './add_source_engines_button';
describe('AddSourceEnginesButton', () => {
const MOCK_ACTIONS = {
openModal: jest.fn(),
};
it('opens the modal on click', () => {
setMockActions(MOCK_ACTIONS);
const wrapper = shallow(<AddSourceEnginesButton />);
const button = wrapper.find(EuiButton);
expect(button).toHaveLength(1);
button.simulate('click');
expect(MOCK_ACTIONS.openModal).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,25 @@
/*
* 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 } from 'kea';
import { EuiButton } from '@elastic/eui';
import { ADD_SOURCE_ENGINES_BUTTON_LABEL } from '../i18n';
import { SourceEnginesLogic } from '../source_engines_logic';
export const AddSourceEnginesButton: React.FC = () => {
const { openModal } = useActions(SourceEnginesLogic);
return (
<EuiButton color="secondary" fill onClick={openModal}>
{ADD_SOURCE_ENGINES_BUTTON_LABEL}
</EuiButton>
);
};

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions, setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButton, EuiButtonEmpty, EuiComboBox, EuiModal } from '@elastic/eui';
import { AddSourceEnginesModal } from './add_source_engines_modal';
describe('AddSourceEnginesModal', () => {
const MOCK_VALUES = {
selectableEngineNames: ['source-engine-1', 'source-engine-2', 'source-engine-3'],
selectedEngineNamesToAdd: ['source-engine-2'],
modalLoading: false,
};
const MOCK_ACTIONS = {
addSourceEngines: jest.fn(),
closeModal: jest.fn(),
onAddEnginesSelection: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(MOCK_VALUES);
setMockActions(MOCK_ACTIONS);
});
it('calls closeAddSourceEnginesModal when the modal is closed', () => {
const wrapper = shallow(<AddSourceEnginesModal />);
wrapper.find(EuiModal).simulate('close');
expect(MOCK_ACTIONS.closeModal).toHaveBeenCalled();
});
describe('combo box', () => {
it('has the proper options and selected options', () => {
const wrapper = shallow(<AddSourceEnginesModal />);
expect(wrapper.find(EuiComboBox).prop('options')).toEqual([
{ label: 'source-engine-1' },
{ label: 'source-engine-2' },
{ label: 'source-engine-3' },
]);
expect(wrapper.find(EuiComboBox).prop('selectedOptions')).toEqual([
{ label: 'source-engine-2' },
]);
});
it('calls setSelectedEngineNamesToAdd when changed', () => {
const wrapper = shallow(<AddSourceEnginesModal />);
wrapper.find(EuiComboBox).simulate('change', [{ label: 'source-engine-3' }]);
expect(MOCK_ACTIONS.onAddEnginesSelection).toHaveBeenCalledWith(['source-engine-3']);
});
});
describe('cancel button', () => {
it('calls closeModal when clicked', () => {
const wrapper = shallow(<AddSourceEnginesModal />);
wrapper.find(EuiButtonEmpty).simulate('click');
expect(MOCK_ACTIONS.closeModal).toHaveBeenCalled();
});
});
describe('save button', () => {
it('is disabled when user has selected no engines', () => {
setMockValues({
...MOCK_VALUES,
selectedEngineNamesToAdd: [],
});
const wrapper = shallow(<AddSourceEnginesModal />);
expect(wrapper.find(EuiButton).prop('disabled')).toEqual(true);
});
it('passes modalLoading state', () => {
setMockValues({
...MOCK_VALUES,
modalLoading: true,
});
const wrapper = shallow(<AddSourceEnginesModal />);
expect(wrapper.find(EuiButton).prop('isLoading')).toEqual(true);
});
it('calls addSourceEngines when clicked', () => {
const wrapper = shallow(<AddSourceEnginesModal />);
wrapper.find(EuiButton).simulate('click');
expect(MOCK_ACTIONS.addSourceEngines).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,68 @@
/*
* 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,
EuiComboBox,
EuiModalFooter,
EuiModal,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { CANCEL_BUTTON_LABEL, SAVE_BUTTON_LABEL } from '../../../../shared/constants';
import {
ADD_SOURCE_ENGINES_MODAL_TITLE,
ADD_SOURCE_ENGINES_MODAL_DESCRIPTION,
ADD_SOURCE_ENGINES_PLACEHOLDER,
} from '../i18n';
import { SourceEnginesLogic } from '../source_engines_logic';
export const AddSourceEnginesModal: React.FC = () => {
const { addSourceEngines, closeModal, onAddEnginesSelection } = useActions(SourceEnginesLogic);
const { selectableEngineNames, selectedEngineNamesToAdd, modalLoading } = useValues(
SourceEnginesLogic
);
return (
<EuiModal onClose={closeModal}>
<EuiModalHeader>
<EuiModalHeaderTitle>{ADD_SOURCE_ENGINES_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText color="subdued">{ADD_SOURCE_ENGINES_MODAL_DESCRIPTION}</EuiText>
<EuiSpacer />
<EuiComboBox
options={selectableEngineNames.map((engineName) => ({ label: engineName }))}
selectedOptions={selectedEngineNamesToAdd.map((engineName) => ({ label: engineName }))}
onChange={(options) => onAddEnginesSelection(options.map((option) => option.label))}
placeholder={ADD_SOURCE_ENGINES_PLACEHOLDER}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={closeModal}>{CANCEL_BUTTON_LABEL}</EuiButtonEmpty>
<EuiButton
disabled={selectedEngineNamesToAdd.length === 0}
isLoading={modalLoading}
onClick={() => addSourceEngines(selectedEngineNamesToAdd)}
fill
>
{SAVE_BUTTON_LABEL}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -0,0 +1,10 @@
/*
* 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 { AddSourceEnginesButton } from './add_source_engines_button';
export { AddSourceEnginesModal } from './add_source_engines_modal';
export { SourceEnginesTable } from './source_engines_table';

View file

@ -0,0 +1,83 @@
/*
* 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 { mountWithIntl, setMockActions, setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiInMemoryTable, EuiButtonIcon } from '@elastic/eui';
import { SourceEnginesTable } from './source_engines_table';
describe('SourceEnginesTable', () => {
const MOCK_VALUES = {
// AppLogic
myRole: {
canManageMetaEngineSourceEngines: true,
},
// SourceEnginesLogic
sourceEngines: [{ name: 'source-engine-1', document_count: 15, field_count: 26 }],
};
const MOCK_ACTIONS = {
removeSourceEngine: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockActions(MOCK_ACTIONS);
setMockValues(MOCK_VALUES);
});
it('renders', () => {
const wrapper = shallow(<SourceEnginesTable />);
expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1);
});
it('contains relevant informatiom from source engines', () => {
const wrapper = mountWithIntl(<SourceEnginesTable />);
expect(wrapper.find(EuiInMemoryTable).text()).toContain('source-engine-1');
expect(wrapper.find(EuiInMemoryTable).text()).toContain('15');
expect(wrapper.find(EuiInMemoryTable).text()).toContain('26');
});
describe('actions column', () => {
it('clicking a remove engine link calls a confirm dialogue before remove the engine', () => {
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValueOnce(true);
const wrapper = mountWithIntl(<SourceEnginesTable />);
wrapper.find(EuiButtonIcon).simulate('click');
expect(confirmSpy).toHaveBeenCalled();
expect(MOCK_ACTIONS.removeSourceEngine).toHaveBeenCalled();
});
it('does not remove an engine if the user cancels the confirmation dialog', () => {
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValueOnce(false);
const wrapper = mountWithIntl(<SourceEnginesTable />);
wrapper.find(EuiButtonIcon).simulate('click');
expect(confirmSpy).toHaveBeenCalled();
expect(MOCK_ACTIONS.removeSourceEngine).not.toHaveBeenCalled();
});
it('does not render the actions column if the user does not have permission to manage the engine', () => {
setMockValues({
...MOCK_VALUES,
myRole: { canManageMetaEngineSourceEngines: false },
});
const wrapper = mountWithIntl(<SourceEnginesTable />);
expect(wrapper.find(EuiButtonIcon)).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,75 @@
/*
* 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 { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui';
import { EuiLinkTo } from '../../../../shared/react_router_helpers';
import { AppLogic } from '../../../app_logic';
import { ENGINE_PATH } from '../../../routes';
import { generateEncodedPath } from '../../../utils/encode_path_params';
import { EngineDetails } from '../../engine/types';
import {
NAME_COLUMN,
DOCUMENT_COUNT_COLUMN,
FIELD_COUNT_COLUMN,
ACTIONS_COLUMN,
} from '../../engines/components/tables/shared_columns';
import { REMOVE_SOURCE_ENGINE_BUTTON_LABEL, REMOVE_SOURCE_ENGINE_CONFIRM_DIALOGUE } from '../i18n';
import { SourceEnginesLogic } from '../source_engines_logic';
export const SourceEnginesTable: React.FC = () => {
const {
myRole: { canManageMetaEngineSourceEngines },
} = useValues(AppLogic);
const { removeSourceEngine } = useActions(SourceEnginesLogic);
const { sourceEngines } = useValues(SourceEnginesLogic);
const columns: Array<EuiBasicTableColumn<EngineDetails>> = [
{
...NAME_COLUMN,
render: (engineName: string) => (
<EuiLinkTo to={generateEncodedPath(ENGINE_PATH, { engineName })}>{engineName}</EuiLinkTo>
),
},
DOCUMENT_COUNT_COLUMN,
FIELD_COUNT_COLUMN,
];
if (canManageMetaEngineSourceEngines) {
columns.push({
name: ACTIONS_COLUMN.name,
actions: [
{
name: REMOVE_SOURCE_ENGINE_BUTTON_LABEL,
description: REMOVE_SOURCE_ENGINE_BUTTON_LABEL,
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (engine: EngineDetails) => {
if (confirm(REMOVE_SOURCE_ENGINE_CONFIRM_DIALOGUE(engine.name))) {
removeSourceEngine(engine.name);
}
},
},
],
});
}
return (
<EuiInMemoryTable
items={sourceEngines}
columns={columns}
pagination={sourceEngines.length > 10}
search={{ box: { incremental: true } }}
/>
);
};

View file

@ -0,0 +1,67 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SOURCE_ENGINES_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.title',
{ defaultMessage: 'Manage engines' }
);
export const ADD_SOURCE_ENGINES_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.addSourceEnginesButtonLabel',
{ defaultMessage: 'Add engines' }
);
export const ADD_SOURCE_ENGINES_MODAL_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.addSourceEnginesModal.title',
{ defaultMessage: 'Add engines' }
);
export const ADD_SOURCE_ENGINES_MODAL_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.addSourceEnginesModal.description',
{ defaultMessage: 'Add additional engines to this meta engine.' }
);
export const ADD_SOURCE_ENGINES_PLACEHOLDER = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.addSourceEnginesPlaceholder',
{ defaultMessage: 'Select engine(s)' }
);
export const ADD_SOURCE_ENGINES_SUCCESS_MESSAGE = (sourceEngineNames: string[]) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.addSourceEnginesSuccessMessage',
{
defaultMessage:
'{sourceEnginesCount, plural, one {# engine has} other {# engines have}} been added to this meta engine.',
values: { sourceEnginesCount: sourceEngineNames.length },
}
);
export const REMOVE_SOURCE_ENGINE_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.appSearch.sourceEngines.removeEngineButton.label',
{ defaultMessage: 'Remove from meta engine' }
);
export const REMOVE_SOURCE_ENGINE_CONFIRM_DIALOGUE = (engineName: string) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.sourceEngines.removeEngineConfirmDialogue.description',
{
defaultMessage:
'This will remove the engine, {engineName}, from this meta engine. All existing settings will be lost. Are you sure?',
values: { engineName },
}
);
export const REMOVE_SOURCE_ENGINE_SUCCESS_MESSAGE = (engineName: string) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.removeSourceEngineSuccessMessage',
{
defaultMessage: 'Engine {engineName} has been removed from this meta engine.',
values: { engineName },
}
);

View file

@ -5,52 +5,88 @@
* 2.0.
*/
import '../../../__mocks__/shallow_useeffect.mock';
import { setMockActions, setMockValues } from '../../../__mocks__';
import '../../../__mocks__/shallow_useeffect.mock';
import '../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiCodeBlock } from '@elastic/eui';
import { EuiPageHeader } from '@elastic/eui';
import { Loading } from '../../../shared/loading';
import { AddSourceEnginesButton, AddSourceEnginesModal, SourceEnginesTable } from './components';
import { SourceEngines } from '.';
const MOCK_ACTIONS = {
// SourceEnginesLogic
fetchSourceEngines: jest.fn(),
};
const MOCK_VALUES = {
dataLoading: false,
sourceEngines: [],
};
describe('SourceEngines', () => {
const MOCK_ACTIONS = {
fetchIndexedEngines: jest.fn(),
fetchSourceEngines: jest.fn(),
};
const MOCK_VALUES = {
// AppLogic
myRole: {
canManageMetaEngineSourceEngines: true,
},
// SourceEnginesLogic
dataLoading: false,
isModalOpen: false,
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(MOCK_VALUES);
setMockActions(MOCK_ACTIONS);
});
describe('non-happy-path states', () => {
it('renders a loading component before data has loaded', () => {
setMockValues({ ...MOCK_VALUES, dataLoading: true });
const wrapper = shallow(<SourceEngines />);
it('renders and calls a function to initialize data', () => {
const wrapper = shallow(<SourceEngines />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
expect(wrapper.find(SourceEnginesTable)).toHaveLength(1);
expect(MOCK_ACTIONS.fetchIndexedEngines).toHaveBeenCalled();
expect(MOCK_ACTIONS.fetchSourceEngines).toHaveBeenCalled();
});
describe('happy-path states', () => {
it('renders and calls a function to initialize data', () => {
setMockValues(MOCK_VALUES);
it('renders the add source engines modal', () => {
setMockValues({
...MOCK_VALUES,
isModalOpen: true,
});
const wrapper = shallow(<SourceEngines />);
expect(wrapper.find(AddSourceEnginesModal)).toHaveLength(1);
});
it('renders a loading component before data has loaded', () => {
setMockValues({ ...MOCK_VALUES, dataLoading: true });
const wrapper = shallow(<SourceEngines />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
describe('page actions', () => {
const getPageHeader = (wrapper: ShallowWrapper) =>
wrapper.find(EuiPageHeader).dive().children().dive();
it('contains a button to add source engines', () => {
const wrapper = shallow(<SourceEngines />);
expect(getPageHeader(wrapper).find(AddSourceEnginesButton)).toHaveLength(1);
});
it('hides the add source engines button if the user does not have permissions', () => {
setMockValues({
...MOCK_VALUES,
myRole: {
canManageMetaEngineSourceEngines: false,
},
});
const wrapper = shallow(<SourceEngines />);
expect(wrapper.find(EuiCodeBlock)).toHaveLength(1);
expect(MOCK_ACTIONS.fetchSourceEngines).toHaveBeenCalled();
expect(getPageHeader(wrapper).find(AddSourceEnginesButton)).toHaveLength(0);
});
});
});

View file

@ -9,29 +9,27 @@ import React, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import { EuiCodeBlock, EuiPageHeader } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiPageHeader, EuiPageContent } from '@elastic/eui';
import { FlashMessages } from '../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { Loading } from '../../../shared/loading';
import { AppLogic } from '../../app_logic';
import { getEngineBreadcrumbs } from '../engine';
import { AddSourceEnginesButton, AddSourceEnginesModal, SourceEnginesTable } from './components';
import { SOURCE_ENGINES_TITLE } from './i18n';
import { SourceEnginesLogic } from './source_engines_logic';
const SOURCE_ENGINES_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.souceEngines.title',
{
defaultMessage: 'Manage engines',
}
);
export const SourceEngines: React.FC = () => {
const { fetchSourceEngines } = useActions(SourceEnginesLogic);
const { dataLoading, sourceEngines } = useValues(SourceEnginesLogic);
const {
myRole: { canManageMetaEngineSourceEngines },
} = useValues(AppLogic);
const { fetchIndexedEngines, fetchSourceEngines } = useActions(SourceEnginesLogic);
const { dataLoading, isModalOpen } = useValues(SourceEnginesLogic);
useEffect(() => {
fetchIndexedEngines();
fetchSourceEngines();
}, []);
@ -40,9 +38,15 @@ export const SourceEngines: React.FC = () => {
return (
<>
<SetPageChrome trail={getEngineBreadcrumbs([SOURCE_ENGINES_TITLE])} />
<EuiPageHeader pageTitle={SOURCE_ENGINES_TITLE} />
<EuiPageHeader
pageTitle={SOURCE_ENGINES_TITLE}
rightSideItems={canManageMetaEngineSourceEngines ? [<AddSourceEnginesButton />] : []}
/>
<FlashMessages />
<EuiCodeBlock language="json">{JSON.stringify(sourceEngines, null, 2)}</EuiCodeBlock>
<EuiPageContent hasBorder>
<SourceEnginesTable />
{isModalOpen && <AddSourceEnginesModal />}
</EuiPageContent>
</>
);
};

View file

@ -6,129 +6,372 @@
*/
import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__';
import { mockRecursivelyFetchEngines } from '../../__mocks__';
import '../../__mocks__/engine_logic.mock';
import { nextTick } from '@kbn/test/jest';
import { EngineLogic } from '../engine';
import { EngineDetails } from '../engine/types';
import { SourceEnginesLogic } from './source_engines_logic';
const DEFAULT_VALUES = {
dataLoading: true,
sourceEngines: [],
};
describe('SourceEnginesLogic', () => {
const { http } = mockHttpValues;
const { mount } = new LogicMounter(SourceEnginesLogic);
const { flashAPIErrors } = mockFlashMessageHelpers;
const { flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
dataLoading: true,
modalLoading: false,
isModalOpen: false,
indexedEngines: [],
indexedEngineNames: [],
sourceEngines: [],
sourceEngineNames: [],
selectedEngineNamesToAdd: [],
selectableEngineNames: [],
};
beforeEach(() => {
jest.clearAllMocks();
mount();
});
it('initializes with default values', () => {
mount();
expect(SourceEnginesLogic.values).toEqual(DEFAULT_VALUES);
});
describe('setSourceEngines', () => {
beforeEach(() => {
SourceEnginesLogic.actions.onSourceEnginesFetch([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[]);
describe('actions', () => {
describe('closeModal', () => {
it('sets isModalOpen and modalLoading to false', () => {
mount({
isModalOpen: true,
modalLoading: true,
});
SourceEnginesLogic.actions.closeModal();
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
isModalOpen: false,
modalLoading: false,
});
});
});
it('sets the source engines', () => {
expect(SourceEnginesLogic.values.sourceEngines).toEqual([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
]);
describe('openModal', () => {
it('sets isModalOpen to true', () => {
mount({
isModalOpen: false,
});
SourceEnginesLogic.actions.openModal();
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
isModalOpen: true,
});
});
});
it('sets dataLoading to false', () => {
expect(SourceEnginesLogic.values.dataLoading).toEqual(false);
describe('onAddEnginesSelection', () => {
it('sets selectedEngineNamesToAdd to the specified value', () => {
mount();
SourceEnginesLogic.actions.onAddEnginesSelection(['source-engine-1', 'source-engine-2']);
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
selectedEngineNamesToAdd: ['source-engine-1', 'source-engine-2'],
});
});
});
describe('setIndexedEngines', () => {
it('sets indexedEngines to the specified value', () => {
mount();
SourceEnginesLogic.actions.setIndexedEngines([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[]);
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
indexedEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
// Selectors
indexedEngineNames: ['source-engine-1', 'source-engine-2'],
selectableEngineNames: ['source-engine-1', 'source-engine-2'],
});
});
});
describe('onSourceEnginesFetch', () => {
it('sets sourceEngines to the specified value and dataLoading to false', () => {
mount();
SourceEnginesLogic.actions.onSourceEnginesFetch([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[]);
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
sourceEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
// Selectors
sourceEngineNames: ['source-engine-1', 'source-engine-2'],
});
});
});
describe('onSourceEnginesAdd', () => {
it('adds to the existing sourceEngines', () => {
mount({
sourceEngines: [
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[],
});
SourceEnginesLogic.actions.onSourceEnginesAdd([
{ name: 'source-engine-3' },
{ name: 'source-engine-4' },
] as EngineDetails[]);
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
sourceEngines: [
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
{ name: 'source-engine-3' },
{ name: 'source-engine-4' },
],
// Selectors
sourceEngineNames: [
'source-engine-1',
'source-engine-2',
'source-engine-3',
'source-engine-4',
],
});
});
});
describe('onSourceEngineRemove', () => {
it('removes an item from the existing sourceEngines', () => {
mount({
sourceEngines: [
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
{ name: 'source-engine-3' },
] as EngineDetails[],
});
SourceEnginesLogic.actions.onSourceEngineRemove('source-engine-2');
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
sourceEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-3' }],
// Selectors
sourceEngineNames: ['source-engine-1', 'source-engine-3'],
});
});
});
});
describe('fetchSourceEngines', () => {
it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => {
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 1,
},
},
results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }],
})
);
jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch');
describe('selectors', () => {
describe('indexedEngineNames', () => {
it('returns a flat array of `indexedEngine.name`s', () => {
mount({
indexedEngines: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
});
SourceEnginesLogic.actions.fetchSourceEngines();
await nextTick();
expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/source_engines', {
query: {
'page[current]': 1,
'page[size]': 25,
},
expect(SourceEnginesLogic.values.indexedEngineNames).toEqual(['a', 'b', 'c']);
});
expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
]);
});
it('display a flash message on error', async () => {
http.get.mockReturnValueOnce(Promise.reject());
mount();
describe('sourceEngineNames', () => {
it('returns a flat array of `sourceEngine.name`s', () => {
mount({
sourceEngines: [{ name: 'd' }, { name: 'e' }],
});
SourceEnginesLogic.actions.fetchSourceEngines();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
expect(SourceEnginesLogic.values.sourceEngineNames).toEqual(['d', 'e']);
});
});
it('recursively fetches a number of pages', async () => {
mount();
jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch');
describe('selectableEngineNames', () => {
it('returns a flat list of indexedEngineNames that are not already in sourceEngineNames', () => {
mount({
indexedEngines: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
sourceEngines: [{ name: 'a' }, { name: 'b' }],
});
// First page
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 2,
},
},
results: [{ name: 'source-engine-1' }],
})
);
expect(SourceEnginesLogic.values.selectableEngineNames).toEqual(['c']);
});
});
});
// Second and final page
http.get.mockReturnValueOnce(
Promise.resolve({
meta: {
page: {
total_pages: 2,
},
},
results: [{ name: 'source-engine-2' }],
})
);
describe('listeners', () => {
describe('fetchSourceEngines', () => {
it('calls onSourceEnginesFetch with all recursively fetched engines', () => {
jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch');
SourceEnginesLogic.actions.fetchSourceEngines();
await nextTick();
SourceEnginesLogic.actions.fetchSourceEngines();
expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([
// First page
{ name: 'source-engine-1' },
// Second and final page
{ name: 'source-engine-2' },
]);
expect(mockRecursivelyFetchEngines).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/app_search/engines/some-engine/source_engines',
})
);
expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
]);
});
});
describe('fetchIndexedEngines', () => {
it('calls setIndexedEngines with all recursively fetched engines', () => {
jest.spyOn(SourceEnginesLogic.actions, 'setIndexedEngines');
SourceEnginesLogic.actions.fetchIndexedEngines();
expect(mockRecursivelyFetchEngines).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/app_search/engines',
query: { type: 'indexed' },
})
);
expect(SourceEnginesLogic.actions.setIndexedEngines).toHaveBeenCalledWith([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
]);
});
});
describe('addSourceEngines', () => {
it('sets modalLoading to true', () => {
mount({ modalLoading: false });
SourceEnginesLogic.actions.addSourceEngines([]);
expect(SourceEnginesLogic.values).toEqual({
...DEFAULT_VALUES,
modalLoading: true,
});
});
describe('on success', () => {
beforeEach(() => {
http.post.mockReturnValue(Promise.resolve());
mount({
indexedEngines: [{ name: 'source-engine-3' }, { name: 'source-engine-4' }],
});
});
it('calls the bulk endpoint, adds source engines to state, and shows a success message', async () => {
jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesAdd');
SourceEnginesLogic.actions.addSourceEngines(['source-engine-3', 'source-engine-4']);
await nextTick();
expect(http.post).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/source_engines/bulk_create',
{
body: JSON.stringify({ source_engine_slugs: ['source-engine-3', 'source-engine-4'] }),
}
);
expect(SourceEnginesLogic.actions.onSourceEnginesAdd).toHaveBeenCalledWith([
{ name: 'source-engine-3' },
{ name: 'source-engine-4' },
]);
expect(setSuccessMessage).toHaveBeenCalledWith(
'2 engines have been added to this meta engine.'
);
});
it('re-initializes the engine and closes the modal', async () => {
jest.spyOn(EngineLogic.actions, 'initializeEngine');
jest.spyOn(SourceEnginesLogic.actions, 'closeModal');
SourceEnginesLogic.actions.addSourceEngines([]);
await nextTick();
expect(EngineLogic.actions.initializeEngine).toHaveBeenCalled();
expect(SourceEnginesLogic.actions.closeModal).toHaveBeenCalled();
});
});
describe('on error', () => {
beforeEach(() => {
http.post.mockReturnValue(Promise.reject());
mount();
});
it('flashes errors and closes the modal', async () => {
jest.spyOn(SourceEnginesLogic.actions, 'closeModal');
SourceEnginesLogic.actions.addSourceEngines([]);
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
expect(SourceEnginesLogic.actions.closeModal).toHaveBeenCalled();
});
});
});
describe('removeSourceEngine', () => {
describe('on success', () => {
beforeEach(() => {
http.delete.mockReturnValue(Promise.resolve());
mount();
});
it('calls the delete endpoint and removes source engines from state', async () => {
jest.spyOn(SourceEnginesLogic.actions, 'onSourceEngineRemove');
SourceEnginesLogic.actions.removeSourceEngine('source-engine-2');
await nextTick();
expect(http.delete).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/source_engines/source-engine-2'
);
expect(SourceEnginesLogic.actions.onSourceEngineRemove).toHaveBeenCalledWith(
'source-engine-2'
);
});
it('shows a success message', async () => {
SourceEnginesLogic.actions.removeSourceEngine('source-engine-2');
await nextTick();
expect(setSuccessMessage).toHaveBeenCalledWith(
'Engine source-engine-2 has been removed from this meta engine.'
);
});
it('re-initializes the engine', async () => {
jest.spyOn(EngineLogic.actions, 'initializeEngine');
SourceEnginesLogic.actions.removeSourceEngine('source-engine-2');
await nextTick();
expect(EngineLogic.actions.initializeEngine).toHaveBeenCalledWith();
});
});
it('displays a flash message on error', async () => {
http.delete.mockReturnValueOnce(Promise.reject());
mount();
SourceEnginesLogic.actions.removeSourceEngine('source-engine-2');
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledTimes(1);
});
});
});
});

View file

@ -4,24 +4,47 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { flashAPIErrors } from '../../../shared/flash_messages';
import { flashAPIErrors, setSuccessMessage } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { recursivelyFetchEngines } from '../../utils/recursively_fetch_engines';
import { EngineLogic } from '../engine';
import { EngineDetails } from '../engine/types';
import { EnginesAPIResponse } from '../engines/types';
interface SourceEnginesLogicValues {
import { ADD_SOURCE_ENGINES_SUCCESS_MESSAGE, REMOVE_SOURCE_ENGINE_SUCCESS_MESSAGE } from './i18n';
export interface SourceEnginesLogicValues {
dataLoading: boolean;
modalLoading: boolean;
isModalOpen: boolean;
indexedEngines: EngineDetails[];
indexedEngineNames: string[];
sourceEngines: EngineDetails[];
sourceEngineNames: string[];
selectableEngineNames: string[];
selectedEngineNamesToAdd: string[];
}
interface SourceEnginesLogicActions {
addSourceEngines: (sourceEngineNames: string[]) => { sourceEngineNames: string[] };
fetchIndexedEngines: () => void;
fetchSourceEngines: () => void;
onSourceEngineRemove: (sourceEngineNameToRemove: string) => { sourceEngineNameToRemove: string };
onSourceEnginesAdd: (
sourceEnginesToAdd: EngineDetails[]
) => { sourceEnginesToAdd: EngineDetails[] };
onSourceEnginesFetch: (
sourceEngines: SourceEnginesLogicValues['sourceEngines']
) => { sourceEngines: SourceEnginesLogicValues['sourceEngines'] };
removeSourceEngine: (sourceEngineName: string) => { sourceEngineName: string };
setIndexedEngines: (indexedEngines: EngineDetails[]) => { indexedEngines: EngineDetails[] };
openModal: () => void;
closeModal: () => void;
onAddEnginesSelection: (
selectedEngineNamesToAdd: string[]
) => { selectedEngineNamesToAdd: string[] };
}
export const SourceEnginesLogic = kea<
@ -29,8 +52,17 @@ export const SourceEnginesLogic = kea<
>({
path: ['enterprise_search', 'app_search', 'source_engines_logic'],
actions: () => ({
addSourceEngines: (sourceEngineNames) => ({ sourceEngineNames }),
fetchIndexedEngines: true,
fetchSourceEngines: true,
onSourceEngineRemove: (sourceEngineNameToRemove) => ({ sourceEngineNameToRemove }),
onSourceEnginesAdd: (sourceEnginesToAdd) => ({ sourceEnginesToAdd }),
onSourceEnginesFetch: (sourceEngines) => ({ sourceEngines }),
removeSourceEngine: (sourceEngineName) => ({ sourceEngineName }),
setIndexedEngines: (indexedEngines) => ({ indexedEngines }),
openModal: true,
closeModal: true,
onAddEnginesSelection: (selectedEngineNamesToAdd) => ({ selectedEngineNamesToAdd }),
}),
reducers: () => ({
dataLoading: [
@ -39,47 +71,119 @@ export const SourceEnginesLogic = kea<
onSourceEnginesFetch: () => false,
},
],
modalLoading: [
false,
{
addSourceEngines: () => true,
closeModal: () => false,
},
],
isModalOpen: [
false,
{
openModal: () => true,
closeModal: () => false,
},
],
indexedEngines: [
[],
{
setIndexedEngines: (_, { indexedEngines }) => indexedEngines,
},
],
selectedEngineNamesToAdd: [
[],
{
closeModal: () => [],
onAddEnginesSelection: (_, { selectedEngineNamesToAdd }) => selectedEngineNamesToAdd,
},
],
sourceEngines: [
[],
{
onSourceEnginesAdd: (sourceEngines, { sourceEnginesToAdd }) => [
...sourceEngines,
...sourceEnginesToAdd,
],
onSourceEnginesFetch: (_, { sourceEngines }) => sourceEngines,
onSourceEngineRemove: (sourceEngines, { sourceEngineNameToRemove }) =>
sourceEngines.filter((sourceEngine) => sourceEngine.name !== sourceEngineNameToRemove),
},
],
}),
listeners: ({ actions }) => ({
fetchSourceEngines: () => {
selectors: {
indexedEngineNames: [
(selectors) => [selectors.indexedEngines],
(indexedEngines) => indexedEngines.map((engine: EngineDetails) => engine.name),
],
sourceEngineNames: [
(selectors) => [selectors.sourceEngines],
(sourceEngines) => sourceEngines.map((engine: EngineDetails) => engine.name),
],
selectableEngineNames: [
(selectors) => [selectors.indexedEngineNames, selectors.sourceEngineNames],
(indexedEngineNames, sourceEngineNames) =>
indexedEngineNames.filter((engineName: string) => !sourceEngineNames.includes(engineName)),
],
},
listeners: ({ actions, values }) => ({
addSourceEngines: async ({ sourceEngineNames }) => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
let enginesAccumulator: EngineDetails[] = [];
try {
await http.post(`/api/app_search/engines/${engineName}/source_engines/bulk_create`, {
body: JSON.stringify({
source_engine_slugs: sourceEngineNames,
}),
});
// We need to recursively fetch all source engines because we put the data
// into an EuiInMemoryTable to enable searching
const recursiveFetchSourceEngines = async (page = 1) => {
try {
const { meta, results }: EnginesAPIResponse = await http.get(
`/api/app_search/engines/${engineName}/source_engines`,
{
query: {
'page[current]': page,
'page[size]': 25,
},
}
);
const sourceEnginesToAdd = values.indexedEngines.filter(({ name }) =>
sourceEngineNames.includes(name)
);
enginesAccumulator = [...enginesAccumulator, ...results];
actions.onSourceEnginesAdd(sourceEnginesToAdd);
setSuccessMessage(ADD_SOURCE_ENGINES_SUCCESS_MESSAGE(sourceEngineNames));
EngineLogic.actions.initializeEngine();
} catch (e) {
flashAPIErrors(e);
} finally {
actions.closeModal();
}
},
fetchSourceEngines: () => {
const { engineName } = EngineLogic.values;
if (page >= meta.page.total_pages) {
actions.onSourceEnginesFetch(enginesAccumulator);
} else {
recursiveFetchSourceEngines(page + 1);
}
} catch (e) {
flashAPIErrors(e);
}
};
recursivelyFetchEngines({
endpoint: `/api/app_search/engines/${engineName}/source_engines`,
onComplete: (engines) => actions.onSourceEnginesFetch(engines),
});
},
fetchIndexedEngines: () => {
recursivelyFetchEngines({
endpoint: '/api/app_search/engines',
onComplete: (engines) => actions.setIndexedEngines(engines),
query: { type: 'indexed' },
});
},
removeSourceEngine: async ({ sourceEngineName }) => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
recursiveFetchSourceEngines();
try {
await http.delete(
`/api/app_search/engines/${engineName}/source_engines/${sourceEngineName}`
);
actions.onSourceEngineRemove(sourceEngineName);
setSuccessMessage(REMOVE_SOURCE_ENGINE_SUCCESS_MESSAGE(sourceEngineName));
// Changing source engines can change schema conflicts and invalid boosts,
// so we re-initialize the engine to re-fetch that data
EngineLogic.actions.initializeEngine(); //
} catch (e) {
flashAPIErrors(e);
}
},
}),
});

View file

@ -0,0 +1,108 @@
/*
* 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 { mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__';
import { nextTick } from '@kbn/test/jest';
import { recursivelyFetchEngines } from './';
describe('recursivelyFetchEngines', () => {
const { http } = mockHttpValues;
const { flashAPIErrors } = mockFlashMessageHelpers;
const MOCK_PAGE_1 = {
meta: {
page: { current: 1, total_pages: 3 },
},
results: [{ name: 'source-engine-1' }],
};
const MOCK_PAGE_2 = {
meta: {
page: { current: 2, total_pages: 3 },
},
results: [{ name: 'source-engine-2' }],
};
const MOCK_PAGE_3 = {
meta: {
page: { current: 3, total_pages: 3 },
},
results: [{ name: 'source-engine-3' }],
};
const MOCK_CALLBACK = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('recursively calls the passed API endpoint and returns all engines to the onComplete callback', async () => {
http.get
.mockReturnValueOnce(Promise.resolve(MOCK_PAGE_1))
.mockReturnValueOnce(Promise.resolve(MOCK_PAGE_2))
.mockReturnValueOnce(Promise.resolve(MOCK_PAGE_3));
recursivelyFetchEngines({
endpoint: '/api/app_search/engines/some-engine/source_engines',
onComplete: MOCK_CALLBACK,
});
await nextTick();
expect(http.get).toHaveBeenCalledTimes(3); // Called once for each page
expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/source_engines', {
query: {
'page[current]': 1,
'page[size]': 25,
},
});
expect(MOCK_CALLBACK).toHaveBeenCalledWith([
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
{ name: 'source-engine-3' },
]);
});
it('passes optional query params', () => {
recursivelyFetchEngines({
endpoint: '/api/app_search/engines/some-engine/engines',
onComplete: MOCK_CALLBACK,
query: { type: 'indexed' },
});
expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/engines', {
query: {
'page[current]': 1,
'page[size]': 25,
type: 'indexed',
},
});
});
it('passes optional custom page sizes', () => {
recursivelyFetchEngines({
endpoint: '/over_9000',
onComplete: MOCK_CALLBACK,
pageSize: 9001,
});
expect(http.get).toHaveBeenCalledWith('/over_9000', {
query: {
'page[current]': 1,
'page[size]': 9001,
},
});
});
it('handles errors', async () => {
http.get.mockReturnValueOnce(Promise.reject('error'));
recursivelyFetchEngines({ endpoint: '/error', onComplete: MOCK_CALLBACK });
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 { flashAPIErrors } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { EngineDetails } from '../../components/engine/types';
import { EnginesAPIResponse } from '../../components/engines/types';
interface Params {
endpoint: string;
onComplete: (engines: EngineDetails[]) => void;
query?: object;
pageSize?: number;
}
export const recursivelyFetchEngines = ({
endpoint,
onComplete,
query = {},
pageSize = 25,
}: Params) => {
const { http } = HttpLogic.values;
let enginesAccumulator: EngineDetails[] = [];
const fetchEngines = async (page = 1) => {
try {
const { meta, results }: EnginesAPIResponse = await http.get(endpoint, {
query: {
'page[current]': page,
'page[size]': pageSize,
...query,
},
});
enginesAccumulator = [...enginesAccumulator, ...results];
if (page >= meta.page.total_pages) {
onComplete(enginesAccumulator);
} else {
fetchEngines(page + 1);
}
} catch (e) {
flashAPIErrors(e);
}
};
fetchEngines();
};

View file

@ -259,47 +259,4 @@ describe('engine routes', () => {
});
});
});
describe('GET /api/app_search/engines/{name}/source_engines', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/app_search/engines/{name}/source_engines',
});
registerEnginesRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('validates correctly with name', () => {
const request = { params: { name: 'test-engine' } };
mockRouter.shouldValidate(request);
});
it('fails validation without name', () => {
const request = { params: {} };
mockRouter.shouldThrow(request);
});
it('fails validation with a non-string name', () => {
const request = { params: { name: 1 } };
mockRouter.shouldThrow(request);
});
it('fails validation with missing query params', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:name/source_engines',
});
});
});
});

View file

@ -95,21 +95,4 @@ export function registerEnginesRoutes({
path: '/as/engines/:name/overview_metrics',
})
);
router.get(
{
path: '/api/app_search/engines/{name}/source_engines',
validate: {
params: schema.object({
name: schema.string(),
}),
query: schema.object({
'page[current]': schema.number(),
'page[size]': schema.number(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:name/source_engines',
})
);
}

View file

@ -20,6 +20,7 @@ import { registerSchemaRoutes } from './schema';
import { registerSearchSettingsRoutes } from './search_settings';
import { registerSearchUIRoutes } from './search_ui';
import { registerSettingsRoutes } from './settings';
import { registerSourceEnginesRoutes } from './source_engines';
import { registerSynonymsRoutes } from './synonyms';
export const registerAppSearchRoutes = (dependencies: RouteDependencies) => {
@ -30,6 +31,7 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => {
registerDocumentsRoutes(dependencies);
registerDocumentRoutes(dependencies);
registerSchemaRoutes(dependencies);
registerSourceEnginesRoutes(dependencies);
registerCurationsRoutes(dependencies);
registerSynonymsRoutes(dependencies);
registerSearchSettingsRoutes(dependencies);

View file

@ -0,0 +1,151 @@
/*
* 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
import { registerSourceEnginesRoutes } from './source_engines';
describe('source engine routes', () => {
describe('GET /api/app_search/engines/{name}/source_engines', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/app_search/engines/{name}/source_engines',
});
registerSourceEnginesRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('validates correctly with name', () => {
const request = { params: { name: 'test-engine' } };
mockRouter.shouldValidate(request);
});
it('fails validation without name', () => {
const request = { params: {} };
mockRouter.shouldThrow(request);
});
it('fails validation with a non-string name', () => {
const request = { params: { name: 1 } };
mockRouter.shouldThrow(request);
});
it('fails validation with missing query params', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:name/source_engines',
});
});
});
describe('POST /api/app_search/engines/{name}/source_engines/bulk_create', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/api/app_search/engines/{name}/source_engines/bulk_create',
});
registerSourceEnginesRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('validates correctly with name', () => {
const request = { params: { name: 'test-engine' }, body: { source_engine_slugs: [] } };
mockRouter.shouldValidate(request);
});
it('fails validation without name', () => {
const request = { params: {}, body: { source_engine_slugs: [] } };
mockRouter.shouldThrow(request);
});
it('fails validation with a non-string name', () => {
const request = { params: { name: 1 }, body: { source_engine_slugs: [] } };
mockRouter.shouldThrow(request);
});
it('fails validation with missing query params', () => {
const request = { params: { name: 'test-engine' }, body: {} };
mockRouter.shouldThrow(request);
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:name/source_engines/bulk_create',
});
});
});
describe('DELETE /api/app_search/engines/{name}/source_engines/{source_engine_name}', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'delete',
path: '/api/app_search/engines/{name}/source_engines/{source_engine_name}',
});
registerSourceEnginesRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('validates correctly with name and source_engine_name', () => {
const request = { params: { name: 'test-engine', source_engine_name: 'source-engine' } };
mockRouter.shouldValidate(request);
});
it('fails validation without name', () => {
const request = { params: { source_engine_name: 'source-engine' } };
mockRouter.shouldThrow(request);
});
it('fails validation with a non-string name', () => {
const request = { params: { name: 1, source_engine_name: 'source-engine' } };
mockRouter.shouldThrow(request);
});
it('fails validation without source_engine_name', () => {
const request = { params: { name: 'test-engine' } };
mockRouter.shouldThrow(request);
});
it('fails validation with a non-string source_engine_name', () => {
const request = { params: { name: 'test-engine', source_engine_name: 1 } };
mockRouter.shouldThrow(request);
});
it('fails validation with missing query params', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:name/source_engines/:source_engine_name',
});
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../plugin';
export function registerSourceEnginesRoutes({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/app_search/engines/{name}/source_engines',
validate: {
params: schema.object({
name: schema.string(),
}),
query: schema.object({
'page[current]': schema.number(),
'page[size]': schema.number(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:name/source_engines',
})
);
router.post(
{
path: '/api/app_search/engines/{name}/source_engines/bulk_create',
validate: {
params: schema.object({
name: schema.string(),
}),
body: schema.object({
source_engine_slugs: schema.arrayOf(schema.string()),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:name/source_engines/bulk_create',
})
);
router.delete(
{
path: '/api/app_search/engines/{name}/source_engines/{source_engine_name}',
validate: {
params: schema.object({
name: schema.string(),
source_engine_name: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:name/source_engines/:source_engine_name',
})
);
}