[App Search] New Add Domain Flyout for Crawler (#106102)

* Added /api/app_search/engines/{name}/crawler/domains to Crawler routes

* New AddDomainLogic

* New AddDomainFormSubmitButton component

* New AddDomainFormErrors component

* New AddDomainForm component

* New AddDomainFlyout component

* Add AddDomainFlyout to CrawlerOverview

* Use exact path for CrawlerOverview in CrawlerRouter

* Clean-up AddDomainFlyout

* Clean-up AddDomainForm

* Clean-up AddDomainFormSubmitButton

* Extract getErrorsFromHttpResponse from flashAPIErrors

* Clean-up AddDomainLogic

* Remove unused imports

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Byron Hulcher 2021-07-25 10:53:17 -04:00 committed by GitHub
parent e45d25dde0
commit f7a308859f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1217 additions and 11 deletions

View file

@ -0,0 +1,69 @@
/*
* 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 { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton, EuiButtonEmpty, EuiFlyout, EuiFlyoutBody } from '@elastic/eui';
import { AddDomainFlyout } from './add_domain_flyout';
import { AddDomainForm } from './add_domain_form';
import { AddDomainFormErrors } from './add_domain_form_errors';
import { AddDomainFormSubmitButton } from './add_domain_form_submit_button';
describe('AddDomainFlyout', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is hidden by default', () => {
const wrapper = shallow(<AddDomainFlyout />);
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
it('displays the flyout when the button is pressed', () => {
const wrapper = shallow(<AddDomainFlyout />);
wrapper.find(EuiButton).simulate('click');
expect(wrapper.find(EuiFlyout)).toHaveLength(1);
});
describe('flyout', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
wrapper = shallow(<AddDomainFlyout />);
wrapper.find(EuiButton).simulate('click');
});
it('displays form errors', () => {
expect(wrapper.find(EuiFlyoutBody).dive().find(AddDomainFormErrors)).toHaveLength(1);
});
it('contains a form to add domains', () => {
expect(wrapper.find(AddDomainForm)).toHaveLength(1);
});
it('contains a cancel buttonn', () => {
wrapper.find(EuiButtonEmpty).simulate('click');
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
it('contains a submit button', () => {
expect(wrapper.find(AddDomainFormSubmitButton)).toHaveLength(1);
});
it('hides the flyout on close', () => {
wrapper.find(EuiFlyout).simulate('close');
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiPortal,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants';
import { AddDomainForm } from './add_domain_form';
import { AddDomainFormErrors } from './add_domain_form_errors';
import { AddDomainFormSubmitButton } from './add_domain_form_submit_button';
export const AddDomainFlyout: React.FC = () => {
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
return (
<>
<EuiButton
size="s"
color="success"
iconType="plusInCircle"
onClick={() => setIsFlyoutVisible(true)}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.openButtonLabel',
{
defaultMessage: 'Add domain',
}
)}
</EuiButton>
{isFlyoutVisible && (
<EuiPortal>
<EuiFlyout onClose={() => setIsFlyoutVisible(false)}>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.title',
{
defaultMessage: 'Add a new domain',
}
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody banner={<AddDomainFormErrors />}>
<EuiText>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.description',
{
defaultMessage:
'You can add multiple domains to this engine\'s web crawler. Add another domain here and modify the entry points and crawl rules from the "Manage" page.',
}
)}
<p />
</EuiText>
<EuiSpacer size="l" />
<AddDomainForm />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => setIsFlyoutVisible(false)}>
{CANCEL_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddDomainFormSubmitButton />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
)}
</>
);
};

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton, EuiFieldText, EuiForm } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { rerender } from '../../../../../test_helpers';
import { AddDomainForm } from './add_domain_form';
const MOCK_VALUES = {
addDomainFormInputValue: 'https://',
entryPointValue: '/',
};
const MOCK_ACTIONS = {
setAddDomainFormInputValue: jest.fn(),
validateDomain: jest.fn(),
};
describe('AddDomainForm', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
jest.clearAllMocks();
setMockActions(MOCK_ACTIONS);
setMockValues(MOCK_VALUES);
wrapper = shallow(<AddDomainForm />);
});
it('renders', () => {
expect(wrapper.find(EuiForm)).toHaveLength(1);
});
it('contains a submit button', () => {
expect(wrapper.find(EuiButton).prop('type')).toEqual('submit');
});
it('validates domain on submit', () => {
wrapper.find(EuiForm).simulate('submit', { preventDefault: jest.fn() });
expect(MOCK_ACTIONS.validateDomain).toHaveBeenCalledTimes(1);
});
describe('url field', () => {
it('uses the value from the logic', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: 'test value',
});
rerender(wrapper);
expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test value');
});
it('sets the value in the logic on change', () => {
wrapper.find(EuiFieldText).simulate('change', { target: { value: 'test value' } });
expect(MOCK_ACTIONS.setAddDomainFormInputValue).toHaveBeenCalledWith('test value');
});
});
describe('validate domain button', () => {
it('is enabled when the input has a value', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: 'https://elastic.co',
});
rerender(wrapper);
expect(wrapper.find(EuiButton).prop('disabled')).toEqual(false);
});
it('is disabled when the input value is empty', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: '',
});
rerender(wrapper);
expect(wrapper.find(EuiButton).prop('disabled')).toEqual(true);
});
});
describe('entry point indicator', () => {
it('is hidden when the entry point is /', () => {
setMockValues({
...MOCK_VALUES,
entryPointValue: '/',
});
rerender(wrapper);
expect(wrapper.find(FormattedMessage)).toHaveLength(0);
});
it('displays the entry point otherwise', () => {
setMockValues({
...MOCK_VALUES,
entryPointValue: '/guide',
});
rerender(wrapper);
expect(wrapper.find(FormattedMessage)).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,105 @@
/*
* 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,
EuiCode,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { AddDomainLogic } from './add_domain_logic';
export const AddDomainForm: React.FC = () => {
const { setAddDomainFormInputValue, validateDomain } = useActions(AddDomainLogic);
const { addDomainFormInputValue, entryPointValue } = useValues(AddDomainLogic);
return (
<>
<EuiForm
onSubmit={(event) => {
event.preventDefault();
validateDomain();
}}
component="form"
>
<EuiFormRow
fullWidth
label={
i18n.translate('xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlLabel', {
defaultMessage: 'Domain URL',
}) /* "Domain URL"*/
}
helpText={
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlHelpText',
{
defaultMessage: 'Domain URLs require a protocol and cannot contain any paths.',
}
)}
</EuiText>
}
>
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiFieldText
autoFocus
placeholder="https://"
value={addDomainFormInputValue}
onChange={(e) => setAddDomainFormInputValue(e.target.value)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton type="submit" fill disabled={addDomainFormInputValue.length === 0}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.validateButtonLabel',
{
defaultMessage: 'Validate Domain',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
{entryPointValue !== '/' && (
<>
<EuiSpacer />
<EuiText size="s">
<p>
<strong>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.crawler.addDomainForm.entryPointLabel"
defaultMessage="Web Crawler entry point has been set as {entryPointValue}"
values={{
entryPointValue: <EuiCode>{entryPointValue}</EuiCode>,
}}
/>
</strong>
</p>
</EuiText>
</>
)}
</EuiForm>
</>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AddDomainFormErrors } from './add_domain_form_errors';
describe('AddDomainFormErrors', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is empty when there are no errors', () => {
setMockValues({
errors: [],
});
const wrapper = shallow(<AddDomainFormErrors />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('displays all the errors from the logic', () => {
setMockValues({
errors: ['first error', 'second error'],
});
const wrapper = shallow(<AddDomainFormErrors />);
expect(wrapper.find('p')).toHaveLength(2);
expect(wrapper.find('p').first().text()).toContain('first error');
expect(wrapper.find('p').last().text()).toContain('second error');
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { useValues } from 'kea';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AddDomainLogic } from './add_domain_logic';
export const AddDomainFormErrors: React.FC = () => {
const { errors } = useValues(AddDomainLogic);
if (errors.length > 0) {
return (
<EuiCallOut
color="danger"
iconType="alert"
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.errorsTitle',
{
defaultMessage: 'Something went wrong. Please address the errors and try again.',
}
)}
>
{errors.map((message, index) => (
<p key={index}>{message}</p>
))}
</EuiCallOut>
);
}
return null;
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { AddDomainFormSubmitButton } from './add_domain_form_submit_button';
describe('AddDomainFormSubmitButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is disabled when the domain has not been validated', () => {
setMockValues({
hasValidationCompleted: false,
});
const wrapper = shallow(<AddDomainFormSubmitButton />);
expect(wrapper.prop('disabled')).toBe(true);
});
it('is enabled when the domain has been validated', () => {
setMockValues({
hasValidationCompleted: true,
});
const wrapper = shallow(<AddDomainFormSubmitButton />);
expect(wrapper.prop('disabled')).toBe(false);
});
it('submits the domain on click', () => {
const submitNewDomain = jest.fn();
setMockActions({
submitNewDomain,
});
setMockValues({
hasValidationCompleted: true,
});
const wrapper = shallow(<AddDomainFormSubmitButton />);
wrapper.find(EuiButton).simulate('click');
expect(submitNewDomain).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AddDomainLogic } from './add_domain_logic';
export const AddDomainFormSubmitButton: React.FC = () => {
const { submitNewDomain } = useActions(AddDomainLogic);
const { hasValidationCompleted } = useValues(AddDomainLogic);
return (
<EuiButton fill type="button" disabled={!hasValidationCompleted} onClick={submitNewDomain}>
{i18n.translate('xpack.enterpriseSearch.appSearch.crawler.addDomainForm.submitButtonLabel', {
defaultMessage: 'Add domain',
})}
</EuiButton>
);
};

View file

@ -0,0 +1,300 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
mockKibanaValues,
} from '../../../../../__mocks__/kea_logic';
import '../../../../__mocks__/engine_logic.mock';
jest.mock('../../crawler_overview_logic', () => ({
CrawlerOverviewLogic: {
actions: {
onReceiveCrawlerData: jest.fn(),
},
},
}));
import { nextTick } from '@kbn/test/jest';
import { CrawlerOverviewLogic } from '../../crawler_overview_logic';
import { CrawlerDomain } from '../../types';
import { AddDomainLogic, AddDomainLogicValues } from './add_domain_logic';
const DEFAULT_VALUES: AddDomainLogicValues = {
addDomainFormInputValue: 'https://',
allowSubmit: false,
entryPointValue: '/',
hasValidationCompleted: false,
errors: [],
};
describe('AddDomainLogic', () => {
const { mount } = new LogicMounter(AddDomainLogic);
const { flashSuccessToast } = mockFlashMessageHelpers;
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
it('has default values', () => {
mount();
expect(AddDomainLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('clearDomainFormInputValue', () => {
beforeAll(() => {
mount({
addDomainFormInputValue: 'http://elastic.co',
entryPointValue: '/foo',
hasValidationCompleted: true,
errors: ['first error', 'second error'],
});
AddDomainLogic.actions.clearDomainFormInputValue();
});
it('should clear the input value', () => {
expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://');
});
it('should clear the entry point value', () => {
expect(AddDomainLogic.values.entryPointValue).toEqual('/');
});
it('should reset validation completion', () => {
expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false);
});
it('should clear errors', () => {
expect(AddDomainLogic.values.errors).toEqual([]);
});
});
describe('onSubmitNewDomainError', () => {
it('should set errors', () => {
mount();
AddDomainLogic.actions.onSubmitNewDomainError(['first error', 'second error']);
expect(AddDomainLogic.values.errors).toEqual(['first error', 'second error']);
});
});
describe('onValidateDomain', () => {
beforeAll(() => {
mount({
addDomainFormInputValue: 'https://elastic.co',
entryPointValue: '/customers',
hasValidationCompleted: true,
errors: ['first error', 'second error'],
});
AddDomainLogic.actions.onValidateDomain('https://swiftype.com', '/site-search');
});
it('should set the input value', () => {
expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://swiftype.com');
});
it('should set the entry point value', () => {
expect(AddDomainLogic.values.entryPointValue).toEqual('/site-search');
});
it('should flag validation as being completed', () => {
expect(AddDomainLogic.values.hasValidationCompleted).toEqual(true);
});
it('should clear errors', () => {
expect(AddDomainLogic.values.errors).toEqual([]);
});
});
describe('setAddDomainFormInputValue', () => {
beforeAll(() => {
mount({
addDomainFormInputValue: 'https://elastic.co',
entryPointValue: '/customers',
hasValidationCompleted: true,
errors: ['first error', 'second error'],
});
AddDomainLogic.actions.setAddDomainFormInputValue('https://swiftype.com/site-search');
});
it('should set the input value', () => {
expect(AddDomainLogic.values.addDomainFormInputValue).toEqual(
'https://swiftype.com/site-search'
);
});
it('should clear the entry point value', () => {
expect(AddDomainLogic.values.entryPointValue).toEqual('/');
});
it('should reset validation completion', () => {
expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false);
});
it('should clear errors', () => {
expect(AddDomainLogic.values.errors).toEqual([]);
});
});
describe('submitNewDomain', () => {
it('should clear errors', () => {
expect(AddDomainLogic.values.errors).toEqual([]);
});
});
});
describe('listeners', () => {
describe('onSubmitNewDomainSuccess', () => {
it('should flash a success toast', () => {
const { navigateToUrl } = mockKibanaValues;
mount();
AddDomainLogic.actions.onSubmitNewDomainSuccess({ id: 'test-domain' } as CrawlerDomain);
expect(flashSuccessToast).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/crawler/domains/test-domain'
);
});
});
describe('submitNewDomain', () => {
it('calls the domains endpoint with a JSON formatted body', async () => {
mount({
addDomainFormInputValue: 'https://elastic.co',
entryPointValue: '/guide',
});
http.post.mockReturnValueOnce(Promise.resolve({}));
AddDomainLogic.actions.submitNewDomain();
await nextTick();
expect(http.post).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/crawler/domains',
{
query: {
respond_with: 'crawler_details',
},
body: JSON.stringify({
name: 'https://elastic.co',
entry_points: [{ value: '/guide' }],
}),
}
);
});
describe('on success', () => {
beforeEach(() => {
mount();
});
it('sets crawler data', async () => {
http.post.mockReturnValueOnce(
Promise.resolve({
domains: [],
})
);
AddDomainLogic.actions.submitNewDomain();
await nextTick();
expect(CrawlerOverviewLogic.actions.onReceiveCrawlerData).toHaveBeenCalledWith({
domains: [],
});
});
it('calls the success callback with the most recent domain', async () => {
http.post.mockReturnValueOnce(
Promise.resolve({
domains: [
{
id: '1',
name: 'https://elastic.co/guide',
},
{
id: '2',
name: 'https://swiftype.co/site-search',
},
],
})
);
jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainSuccess');
AddDomainLogic.actions.submitNewDomain();
await nextTick();
expect(AddDomainLogic.actions.onSubmitNewDomainSuccess).toHaveBeenCalledWith({
id: '2',
url: 'https://swiftype.co/site-search',
});
});
});
describe('on error', () => {
beforeEach(() => {
mount();
jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainError');
});
it('passes error messages to the error callback', async () => {
http.post.mockReturnValueOnce(
Promise.reject({
body: {
attributes: {
errors: ['first error', 'second error'],
},
},
})
);
AddDomainLogic.actions.submitNewDomain();
await nextTick();
expect(AddDomainLogic.actions.onSubmitNewDomainError).toHaveBeenCalledWith([
'first error',
'second error',
]);
});
});
});
describe('validateDomain', () => {
it('extracts the domain and entrypoint and passes them to the callback ', () => {
mount({ addDomainFormInputValue: 'https://swiftype.com/site-search' });
jest.spyOn(AddDomainLogic.actions, 'onValidateDomain');
AddDomainLogic.actions.validateDomain();
expect(AddDomainLogic.actions.onValidateDomain).toHaveBeenCalledWith(
'https://swiftype.com',
'/site-search'
);
});
});
});
describe('selectors', () => {
describe('allowSubmit', () => {
it('gets set true when validation is completed', () => {
mount({ hasValidationCompleted: false });
expect(AddDomainLogic.values.allowSubmit).toEqual(false);
mount({ hasValidationCompleted: true });
expect(AddDomainLogic.values.allowSubmit).toEqual(true);
});
});
});
});

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { i18n } from '@kbn/i18n';
import { flashSuccessToast } from '../../../../../shared/flash_messages';
import { getErrorsFromHttpResponse } from '../../../../../shared/flash_messages/handle_api_errors';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { ENGINE_CRAWLER_DOMAIN_PATH } from '../../../../routes';
import { EngineLogic, generateEnginePath } from '../../../engine';
import { CrawlerOverviewLogic } from '../../crawler_overview_logic';
import { CrawlerDataFromServer, CrawlerDomain } from '../../types';
import { crawlerDataServerToClient } from '../../utils';
import { extractDomainAndEntryPointFromUrl } from './utils';
export interface AddDomainLogicValues {
addDomainFormInputValue: string;
allowSubmit: boolean;
hasValidationCompleted: boolean;
entryPointValue: string;
errors: string[];
}
export interface AddDomainLogicActions {
clearDomainFormInputValue(): void;
setAddDomainFormInputValue(newValue: string): string;
onSubmitNewDomainError(errors: string[]): { errors: string[] };
onSubmitNewDomainSuccess(domain: CrawlerDomain): { domain: CrawlerDomain };
onValidateDomain(
newValue: string,
newEntryPointValue: string
): { newValue: string; newEntryPointValue: string };
submitNewDomain(): void;
validateDomain(): void;
}
const DEFAULT_SELECTOR_VALUES = {
addDomainFormInputValue: 'https://',
entryPointValue: '/',
};
export const AddDomainLogic = kea<MakeLogicType<AddDomainLogicValues, AddDomainLogicActions>>({
path: ['enterprise_search', 'app_search', 'crawler', 'add_domain'],
actions: () => ({
clearDomainFormInputValue: true,
setAddDomainFormInputValue: (newValue) => newValue,
onSubmitNewDomainSuccess: (domain) => ({ domain }),
onSubmitNewDomainError: (errors) => ({ errors }),
onValidateDomain: (newValue, newEntryPointValue) => ({
newValue,
newEntryPointValue,
}),
submitNewDomain: true,
validateDomain: true,
}),
reducers: () => ({
addDomainFormInputValue: [
DEFAULT_SELECTOR_VALUES.addDomainFormInputValue,
{
clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.addDomainFormInputValue,
setAddDomainFormInputValue: (_, newValue: string) => newValue,
onValidateDomain: (_, { newValue }: { newValue: string }) => newValue,
},
],
entryPointValue: [
DEFAULT_SELECTOR_VALUES.entryPointValue,
{
clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue,
setAddDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue,
onValidateDomain: (_, { newEntryPointValue }) => newEntryPointValue,
},
],
// TODO When 4-step validation is added this will become a selector as
// we'll use individual step results to determine whether this is true/false
hasValidationCompleted: [
false,
{
clearDomainFormInputValue: () => false,
setAddDomainFormInputValue: () => false,
onValidateDomain: () => true,
},
],
errors: [
[],
{
clearDomainFormInputValue: () => [],
setAddDomainFormInputValue: () => [],
onValidateDomain: () => [],
submitNewDomain: () => [],
onSubmitNewDomainError: (_, { errors }) => errors,
},
],
}),
selectors: ({ selectors }) => ({
// TODO include selectors.blockingFailures once 4-step validation is migrated
allowSubmit: [
() => [selectors.hasValidationCompleted], // should eventually also contain selectors.hasBlockingFailures when that is added
(hasValidationCompleted: boolean) => hasValidationCompleted, // && !hasBlockingFailures
],
}),
listeners: ({ actions, values }) => ({
onSubmitNewDomainSuccess: ({ domain }) => {
flashSuccessToast(
i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.domainsTable.action.add.successMessage',
{
defaultMessage: "Successfully added domain '{domainUrl}'",
values: {
domainUrl: domain.url,
},
}
)
);
KibanaLogic.values.navigateToUrl(
generateEnginePath(ENGINE_CRAWLER_DOMAIN_PATH, { domainId: domain.id })
);
},
submitNewDomain: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const requestBody = JSON.stringify({
name: values.addDomainFormInputValue.trim(),
entry_points: [{ value: values.entryPointValue }],
});
try {
const response = await http.post(`/api/app_search/engines/${engineName}/crawler/domains`, {
query: {
respond_with: 'crawler_details',
},
body: requestBody,
});
const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer);
CrawlerOverviewLogic.actions.onReceiveCrawlerData(crawlerData);
const newDomain = crawlerData.domains[crawlerData.domains.length - 1];
if (newDomain) {
actions.onSubmitNewDomainSuccess(newDomain);
}
// If there is not a new domain, that means the server responded with a 200 but
// didn't actually persist the new domain to our BE, and we take no action
} catch (e) {
// we surface errors inside the form instead of in flash messages
const errorMessages = getErrorsFromHttpResponse(e);
actions.onSubmitNewDomainError(errorMessages);
}
},
validateDomain: () => {
const { domain, entryPoint } = extractDomainAndEntryPointFromUrl(
values.addDomainFormInputValue.trim()
);
actions.onValidateDomain(domain, entryPoint);
},
}),
});

View file

@ -0,0 +1,23 @@
/*
* 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 { extractDomainAndEntryPointFromUrl } from './utils';
describe('extractDomainAndEntryPointFromUrl', () => {
it('extracts a provided entry point and domain', () => {
expect(extractDomainAndEntryPointFromUrl('https://elastic.co/guide')).toEqual({
domain: 'https://elastic.co',
entryPoint: '/guide',
});
});
it('provides a default entry point if there is only a domain', () => {
expect(extractDomainAndEntryPointFromUrl('https://elastic.co')).toEqual({
domain: 'https://elastic.co',
entryPoint: '/',
});
});
});

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.
*/
export const extractDomainAndEntryPointFromUrl = (
url: string
): { domain: string; entryPoint: string } => {
let domain = url;
let entryPoint = '/';
const pathSlashIndex = url.search(/[^\:\/]\//);
if (pathSlashIndex !== -1) {
domain = url.substring(0, pathSlashIndex + 1);
entryPoint = url.substring(pathSlashIndex + 1);
}
return { domain, entryPoint };
};

View file

@ -13,6 +13,7 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { AddDomainFlyout } from './components/add_domain/add_domain_flyout';
import { DomainsTable } from './components/domains_table';
import { CrawlerOverview } from './crawler_overview';
@ -44,7 +45,7 @@ describe('CrawlerOverview', () => {
// TODO test for CrawlRequestsTable after it is built in a future PR
// TODO test for AddDomainForm after it is built in a future PR
expect(wrapper.find(AddDomainFlyout)).toHaveLength(1);
// TODO test for empty state after it is built in a future PR
});

View file

@ -9,9 +9,14 @@ import React, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
import { AddDomainFlyout } from './components/add_domain/add_domain_flyout';
import { DomainsTable } from './components/domains_table';
import { CRAWLER_TITLE } from './constants';
import { CrawlerOverviewLogic } from './crawler_overview_logic';
@ -31,6 +36,21 @@ export const CrawlerOverview: React.FC = () => {
pageHeader={{ pageTitle: CRAWLER_TITLE }}
isLoading={dataLoading}
>
<EuiFlexGroup direction="row" alignItems="stretch">
<EuiFlexItem>
<EuiTitle size="s">
<h3>
{i18n.translate('xpack.enterpriseSearch.appSearch.crawler.domainsTitle', {
defaultMessage: 'Domains',
})}
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddDomainFlyout />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<DomainsTable />
</AppSearchPageTemplate>
);

View file

@ -8,13 +8,15 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { ENGINE_CRAWLER_PATH } from '../../routes';
import { CrawlerLanding } from './crawler_landing';
import { CrawlerOverview } from './crawler_overview';
export const CrawlerRouter: React.FC = () => {
return (
<Switch>
<Route>
<Route exact path={ENGINE_CRAWLER_PATH}>
{process.env.NODE_ENV === 'development' ? <CrawlerOverview /> : <CrawlerLanding />}
</Route>
</Switch>

View file

@ -9,7 +9,7 @@ import '../../__mocks__/kea_logic/kibana_logic.mock';
import { FlashMessagesLogic } from './flash_messages_logic';
import { flashAPIErrors } from './handle_api_errors';
import { flashAPIErrors, getErrorsFromHttpResponse } from './handle_api_errors';
describe('flashAPIErrors', () => {
const mockHttpError = {
@ -68,10 +68,29 @@ describe('flashAPIErrors', () => {
try {
flashAPIErrors(Error('whatever') as any);
} catch (e) {
expect(e.message).toEqual('whatever');
expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([
{ type: 'error', message: 'An unexpected error occurred' },
{ type: 'error', message: expect.any(String) },
]);
}
});
});
describe('getErrorsFromHttpResponse', () => {
it('should return errors from the response if present', () => {
expect(
getErrorsFromHttpResponse({
body: { attributes: { errors: ['first error', 'second error'] } },
} as any)
).toEqual(['first error', 'second error']);
});
it('should return a message from the responnse if no errors', () => {
expect(getErrorsFromHttpResponse({ body: { message: 'test message' } } as any)).toEqual([
'test message',
]);
});
it('should return the a default message otherwise', () => {
expect(getErrorsFromHttpResponse({} as any)).toEqual([expect.any(String)]);
});
});

View file

@ -40,13 +40,22 @@ export const defaultErrorMessage = i18n.translate(
}
);
export const getErrorsFromHttpResponse = (response: HttpResponse<ErrorResponse>) => {
return Array.isArray(response?.body?.attributes?.errors)
? response.body!.attributes.errors
: [response?.body?.message || defaultErrorMessage];
};
/**
* Converts API/HTTP errors into user-facing Flash Messages
*/
export const flashAPIErrors = (error: HttpResponse<ErrorResponse>, { isQueued }: Options = {}) => {
const errorFlashMessages: IFlashMessage[] = Array.isArray(error?.body?.attributes?.errors)
? error.body!.attributes.errors.map((message) => ({ type: 'error', message }))
: [{ type: 'error', message: error?.body?.message || defaultErrorMessage }];
export const flashAPIErrors = (
response: HttpResponse<ErrorResponse>,
{ isQueued }: Options = {}
) => {
const errorFlashMessages: IFlashMessage[] = getErrorsFromHttpResponse(
response
).map((message) => ({ type: 'error', message }));
if (isQueued) {
FlashMessagesLogic.actions.setQueuedMessages(errorFlashMessages);
@ -56,7 +65,7 @@ export const flashAPIErrors = (error: HttpResponse<ErrorResponse>, { isQueued }:
// If this was a programming error or a failed request (such as a CORS) error,
// we rethrow the error so it shows up in the developer console
if (!error?.body?.message) {
throw error;
if (!response?.body?.message) {
throw response;
}
};

View file

@ -43,6 +43,62 @@ describe('crawler routes', () => {
});
});
describe('POST /api/app_search/engines/{name}/crawler/domains', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/api/app_search/engines/{name}/crawler/domains',
});
registerCrawlerRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request to enterprise search', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/api/as/v0/engines/:name/crawler/domains',
});
});
it('validates correctly with params and body', () => {
const request = {
params: { name: 'some-engine' },
body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] },
};
mockRouter.shouldValidate(request);
});
it('accepts a query param', () => {
const request = {
params: { name: 'some-engine' },
body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] },
query: { respond_with: 'crawler_details' },
};
mockRouter.shouldValidate(request);
});
it('fails validation without a name param', () => {
const request = {
params: {},
body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] },
};
mockRouter.shouldThrow(request);
});
it('fails validation without a body', () => {
const request = {
params: { name: 'some-engine' },
body: {},
};
mockRouter.shouldThrow(request);
});
});
describe('DELETE /api/app_search/engines/{name}/crawler/domains/{id}', () => {
let mockRouter: MockRouter;

View file

@ -27,6 +27,31 @@ export function registerCrawlerRoutes({
})
);
router.post(
{
path: '/api/app_search/engines/{name}/crawler/domains',
validate: {
params: schema.object({
name: schema.string(),
}),
body: schema.object({
name: schema.string(),
entry_points: schema.arrayOf(
schema.object({
value: schema.string(),
})
),
}),
query: schema.object({
respond_with: schema.maybe(schema.string()),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/api/as/v0/engines/:name/crawler/domains',
})
);
router.delete(
{
path: '/api/app_search/engines/{name}/crawler/domains/{id}',