From dde5e00815b8b4c0a546565bd96b73fa11fa827f Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 4 Jan 2021 11:09:43 -0800 Subject: [PATCH] [App Search] Final Document Creation API, Logic, & Summary/Error views (#86822) * [Setup] Server API route * [Cleanup] Remove unnecessary DocumentCreationSteps - errors can/should be shown in the EuiFlyoutBody banner (better UX since the JSON/file is right there for reference) vs its own page - No need to distinguish between ShowErrorSummary and ShowSuccessSummary + placeholder Summary view for now * Add DocumentCreationLogic file upload logic * Update creation form components to show error/warning feedback * Add final post-upload summary view - split up into subcomponents for easier reading/testing * [lint] oops, double licenses * [PR feedback] map -> forEach * [PR feedback] Reset form state on flyout close Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../document_creation/constants.tsx | 28 ++ .../paste_json_text.test.tsx | 30 ++ .../paste_json_text.tsx | 12 +- .../upload_json_file.test.tsx | 44 +- .../upload_json_file.tsx | 17 +- .../errors.test.tsx | 50 ++ .../creation_response_components/errors.tsx | 36 ++ .../creation_response_components/index.ts | 8 + .../summary.test.tsx | 91 ++++ .../creation_response_components/summary.tsx | 96 ++++ .../summary_documents.test.tsx | 46 ++ .../summary_documents.tsx | 64 +++ .../summary_section.scss | 26 + .../summary_section.test.tsx | 73 +++ .../summary_section.tsx | 60 +++ .../summary_sections.test.tsx | 168 +++++++ .../summary_sections.tsx | 117 +++++ .../document_creation_flyout.test.tsx | 26 +- .../document_creation_flyout.tsx | 9 +- .../document_creation_logic.test.ts | 450 +++++++++++++++++- .../document_creation_logic.ts | 150 +++++- .../components/document_creation/types.ts | 20 +- .../document_creation/utils.test.ts | 20 + .../components/document_creation/utils.ts | 21 + .../routes/app_search/documents.test.ts | 55 ++- .../server/routes/app_search/documents.ts | 24 + .../server/routes/app_search/index.ts | 3 +- 27 files changed, 1687 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/constants.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/constants.tsx index 27c3410767d8a..c685ed8863985 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/constants.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/constants.tsx @@ -16,6 +16,34 @@ export const FLYOUT_CONTINUE_BUTTON = i18n.translate( 'xpack.enterpriseSearch.appSearch.documentCreation.flyoutContinue', { defaultMessage: 'Continue' } ); +export const FLYOUT_CLOSE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.modalClose', + { defaultMessage: 'Close' } +); + +export const DOCUMENT_CREATION_ERRORS = { + TITLE: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.errorsTitle', { + defaultMessage: 'Something went wrong. Please address the errors and try again.', + }), + NO_FILE: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.noFileFound', { + defaultMessage: 'No file found.', + }), + NO_VALID_FILE: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.noValidFile', { + defaultMessage: 'Problem parsing file.', + }), + NOT_VALID: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.notValidJson', { + defaultMessage: 'Document contents must be a valid JSON array or object.', + }), +}; +export const DOCUMENT_CREATION_WARNINGS = { + TITLE: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.warningsTitle', { + defaultMessage: 'Warning!', + }), + LARGE_FILE: i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.largeFile', { + defaultMessage: + "You're uploading an extremely large file. This could potentially lock your browser, or take a very long time to process. If possible, try splitting your data up into multiple smaller files.", + }), +}; // This is indented the way it is to work with ApiCodeExample. // Use dedent() when calling this alone diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx index 50e4d473e5f78..39c6abcaab7b3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.test.tsx @@ -11,11 +11,14 @@ import React from 'react'; import { shallow } from 'enzyme'; import { EuiTextArea, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { Errors } from '../creation_response_components'; import { PasteJsonText, FlyoutHeader, FlyoutBody, FlyoutFooter } from './paste_json_text'; describe('PasteJsonText', () => { const values = { textInput: 'hello world', + isUploading: false, + errors: [], configuredLimits: { engine: { maxDocumentByteSize: 102400, @@ -24,6 +27,7 @@ describe('PasteJsonText', () => { }; const actions = { setTextInput: jest.fn(), + onSubmitJson: jest.fn(), closeDocumentCreation: jest.fn(), }; @@ -58,6 +62,16 @@ describe('PasteJsonText', () => { textarea.simulate('change', { target: { value: 'dolor sit amet' } }); expect(actions.setTextInput).toHaveBeenCalledWith('dolor sit amet'); }); + + it('shows an error banner and sets invalid form props if errors exist', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(false); + + setMockValues({ ...values, errors: ['some error'] }); + rerender(wrapper); + expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(true); + expect(wrapper.prop('banner').type).toEqual(Errors); + }); }); describe('FlyoutFooter', () => { @@ -68,6 +82,13 @@ describe('PasteJsonText', () => { expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); + it('submits json', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.onSubmitJson).toHaveBeenCalled(); + }); + it('disables/enables the Continue button based on whether text has been entered', () => { const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(false); @@ -76,5 +97,14 @@ describe('PasteJsonText', () => { rerender(wrapper); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx index ad83e0eb1a286..b1f83095f30af 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx @@ -25,6 +25,7 @@ import { import { AppLogic } from '../../../app_logic'; import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CANCEL_BUTTON, FLYOUT_CONTINUE_BUTTON } from '../constants'; +import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../'; import './paste_json_text.scss'; @@ -55,11 +56,11 @@ export const FlyoutBody: React.FC = () => { const { configuredLimits } = useValues(AppLogic); const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; - const { textInput } = useValues(DocumentCreationLogic); + const { textInput, errors } = useValues(DocumentCreationLogic); const { setTextInput } = useActions(DocumentCreationLogic); return ( - + }>

{i18n.translate( @@ -76,6 +77,7 @@ export const FlyoutBody: React.FC = () => { setTextInput(e.target.value)} + isInvalid={errors.length > 0} aria-label={i18n.translate( 'xpack.enterpriseSearch.appSearch.documentCreation.pasteJsonText.label', { defaultMessage: 'Paste JSON here' } @@ -89,8 +91,8 @@ export const FlyoutBody: React.FC = () => { }; export const FlyoutFooter: React.FC = () => { - const { textInput } = useValues(DocumentCreationLogic); - const { closeDocumentCreation } = useActions(DocumentCreationLogic); + const { textInput, isUploading } = useValues(DocumentCreationLogic); + const { onSubmitJson, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( @@ -99,7 +101,7 @@ export const FlyoutFooter: React.FC = () => { {FLYOUT_CANCEL_BUTTON} - + {FLYOUT_CONTINUE_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx index 72a245df817ba..a5cb1885d9a04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx @@ -3,11 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ import { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; import { rerender } from '../../../../__mocks__'; @@ -16,12 +11,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { EuiFilePicker, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { Errors } from '../creation_response_components'; import { UploadJsonFile, FlyoutHeader, FlyoutBody, FlyoutFooter } from './upload_json_file'; describe('UploadJsonFile', () => { const mockFile = new File(['mock'], 'mock.json', { type: 'application/json' }); const values = { fileInput: null, + isUploading: false, + errors: [], configuredLimits: { engine: { maxDocumentByteSize: 102400, @@ -30,6 +28,7 @@ describe('UploadJsonFile', () => { }; const actions = { setFileInput: jest.fn(), + onSubmitFile: jest.fn(), closeDocumentCreation: jest.fn(), }; @@ -63,6 +62,25 @@ describe('UploadJsonFile', () => { wrapper.find(EuiFilePicker).simulate('change', []); expect(actions.setFileInput).toHaveBeenCalledWith(null); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(true); + }); + + it('shows an error banner and sets invalid form props if errors exist', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(false); + + setMockValues({ ...values, errors: ['some error'] }); + rerender(wrapper); + expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(true); + expect(wrapper.prop('banner').type).toEqual(Errors); + }); }); describe('FlyoutFooter', () => { @@ -73,6 +91,13 @@ describe('UploadJsonFile', () => { expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); + it('submits the json file', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.onSubmitFile).toHaveBeenCalled(); + }); + it('disables/enables the Continue button based on whether files have been uploaded', () => { const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); @@ -81,5 +106,14 @@ describe('UploadJsonFile', () => { rerender(wrapper); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx index 6c5b1de79c320..86841223c7255 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx @@ -3,11 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ import React from 'react'; import { useValues, useActions } from 'kea'; @@ -30,6 +25,7 @@ import { import { AppLogic } from '../../../app_logic'; import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CANCEL_BUTTON, FLYOUT_CONTINUE_BUTTON } from '../constants'; +import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../'; export const UploadJsonFile: React.FC = () => ( @@ -59,10 +55,11 @@ export const FlyoutBody: React.FC = () => { const { configuredLimits } = useValues(AppLogic); const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; + const { isUploading, errors } = useValues(DocumentCreationLogic); const { setFileInput } = useActions(DocumentCreationLogic); return ( - + }>

{i18n.translate( @@ -80,14 +77,16 @@ export const FlyoutBody: React.FC = () => { onChange={(files) => setFileInput(files?.length ? files[0] : null)} accept="application/json" fullWidth + isLoading={isUploading} + isInvalid={errors.length > 0} /> ); }; export const FlyoutFooter: React.FC = () => { - const { fileInput } = useValues(DocumentCreationLogic); - const { closeDocumentCreation } = useActions(DocumentCreationLogic); + const { fileInput, isUploading } = useValues(DocumentCreationLogic); + const { onSubmitFile, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( @@ -96,7 +95,7 @@ export const FlyoutFooter: React.FC = () => { {FLYOUT_CANCEL_BUTTON} - + {FLYOUT_CONTINUE_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx new file mode 100644 index 0000000000000..ec73184621b53 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiCallOut } from '@elastic/eui'; + +import { Errors } from './'; + +describe('Errors', () => { + it('does not render if no errors or warnings to render', () => { + setMockValues({ errors: [], warnings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + }); + + it('renders errors', () => { + setMockValues({ errors: ['error 1', 'error 2'], warnings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiCallOut).prop('title')).toEqual( + 'Something went wrong. Please address the errors and try again.' + ); + expect(wrapper.find('p').first().text()).toEqual('error 1'); + expect(wrapper.find('p').last().text()).toEqual('error 2'); + }); + + it('renders warnings', () => { + setMockValues({ errors: [], warnings: ['document size warning'] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiCallOut).prop('title')).toEqual('Warning!'); + expect(wrapper.find('p').text()).toEqual('document size warning'); + }); + + it('renders both errors and warnings', () => { + setMockValues({ errors: ['some error'], warnings: ['some warning'] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx new file mode 100644 index 0000000000000..cf0c4e1c46a13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues } from 'kea'; + +import { EuiCallOut } from '@elastic/eui'; + +import { DOCUMENT_CREATION_ERRORS, DOCUMENT_CREATION_WARNINGS } from '../constants'; +import { DocumentCreationLogic } from '../'; + +export const Errors: React.FC = () => { + const { errors, warnings } = useValues(DocumentCreationLogic); + + return ( + <> + {errors.length > 0 && ( + + {errors.map((message, index) => ( +

{message}

+ ))} + + )} + {warnings.length > 0 && ( + + {warnings.map((message, index) => ( +

{message}

+ ))} +
+ )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts new file mode 100644 index 0000000000000..eb4aec46d1f08 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Errors } from './errors'; +export { Summary } from './summary'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx new file mode 100644 index 0000000000000..9882166f63ba0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlyoutBody, EuiCallOut, EuiButton } from '@elastic/eui'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; +import { Summary, FlyoutHeader, FlyoutBody, FlyoutFooter } from './summary'; + +describe('Summary', () => { + const values = { + summary: { + invalidDocuments: { + total: 0, + }, + }, + }; + const actions = { + setCreationStep: jest.fn(), + closeDocumentCreation: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(FlyoutHeader)).toHaveLength(1); + expect(wrapper.find(FlyoutBody)).toHaveLength(1); + expect(wrapper.find(FlyoutFooter)).toHaveLength(1); + }); + + describe('FlyoutHeader', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('h2').text()).toEqual('Indexing summary'); + }); + }); + + describe('FlyoutBody', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(InvalidDocumentsSummary)).toHaveLength(1); + expect(wrapper.find(ValidDocumentsSummary)).toHaveLength(1); + expect(wrapper.find(SchemaFieldsSummary)).toHaveLength(1); + }); + + it('shows an error callout as a flyout banner when the upload contained invalid document(s)', () => { + setMockValues({ summary: { invalidDocuments: { total: 1 } } }); + const wrapper = shallow(); + const banner = wrapper.find(EuiFlyoutBody).prop('banner') as any; + + expect(banner.type).toEqual(EuiCallOut); + expect(banner.props.color).toEqual('danger'); + expect(banner.props.iconType).toEqual('alert'); + expect(banner.props.title).toEqual( + 'Something went wrong. Please address the errors and try again.' + ); + }); + }); + + describe('FlyoutFooter', () => { + it('closes the flyout', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.closeDocumentCreation).toHaveBeenCalled(); + }); + + it('shows a "Fix errors" button when the upload contained invalid document(s)', () => { + setMockValues({ summary: { invalidDocuments: { total: 5 } } }); + const wrapper = shallow(); + + wrapper.find(EuiButton).last().simulate('click'); + expect(actions.setCreationStep).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx new file mode 100644 index 0000000000000..7c7b2c805a710 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues, useActions } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiCallOut, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; + +import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CLOSE_BUTTON, DOCUMENT_CREATION_ERRORS } from '../constants'; +import { DocumentCreationStep } from '../types'; +import { DocumentCreationLogic } from '../'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; + +export const Summary: React.FC = () => { + return ( + <> + + + + + ); +}; + +export const FlyoutHeader: React.FC = () => { + return ( + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.showSummary.title', { + defaultMessage: 'Indexing summary', + })} +

+
+
+ ); +}; + +export const FlyoutBody: React.FC = () => { + const { summary } = useValues(DocumentCreationLogic); + const hasInvalidDocuments = summary.invalidDocuments.total > 0; + const invalidDocumentsBanner = ( + + ); + + return ( + + + + + + ); +}; + +export const FlyoutFooter: React.FC = () => { + const { setCreationStep, closeDocumentCreation } = useActions(DocumentCreationLogic); + const { summary } = useValues(DocumentCreationLogic); + const hasInvalidDocuments = summary.invalidDocuments.total > 0; + + return ( + + + + {FLYOUT_CLOSE_BUTTON} + + {hasInvalidDocuments && ( + + setCreationStep(DocumentCreationStep.AddDocuments)}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.fixErrors', + { defaultMessage: 'Fix errors' } + )} + + + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx new file mode 100644 index 0000000000000..790b0b7197383 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiCodeBlock, EuiCallOut } from '@elastic/eui'; + +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +describe('ExampleDocumentJson', () => { + const exampleDocument = { hello: 'world' }; + const expectedJson = `{ + "hello": "world" +}`; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCodeBlock).prop('children')).toEqual(expectedJson); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + }); + + it('renders invalid documents with error callouts', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('h3').text()).toEqual('This document was not indexed!'); + expect(wrapper.find(EuiCallOut)).toHaveLength(2); + expect(wrapper.find(EuiCallOut).first().prop('title')).toEqual('Bad JSON error'); + expect(wrapper.find(EuiCallOut).last().prop('title')).toEqual('Schema error'); + }); +}); + +describe('MoreDocumentsText', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('p').text()).toEqual('and 100 other documents.'); + + wrapper.setProps({ documents: 1 }); + expect(wrapper.find('p').text()).toEqual('and 1 other document.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx new file mode 100644 index 0000000000000..338020d26dec0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiCodeBlock, EuiCallOut, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface ExampleDocumentJsonProps { + document: object; + errors?: string[]; +} +export const ExampleDocumentJson: React.FC = ({ document, errors }) => { + return ( + <> + {errors && ( + <> + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.documentNotIndexed', + { defaultMessage: 'This document was not indexed!' } + )} +

+
+ + {errors.map((errorMessage, index) => ( + + + + + ))} + + )} + + {JSON.stringify(document, null, 2)} + + + + ); +}; + +interface MoreDocumentsTextProps { + documents: number; +} +export const MoreDocumentsText: React.FC = ({ documents }) => { + return ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.otherDocuments', + { + defaultMessage: + 'and {documents, number} other {documents, plural, one {document} other {documents}}.', + values: { documents }, + } + )} +

+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss new file mode 100644 index 0000000000000..029fcdd25554c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.documentCreationSummarySection { + padding: $euiSize $euiSizeM; + color: $euiTextSubduedColor; + border-top: $euiBorderThin; + border-bottom: $euiBorderThin; + + & + & { + border-top: 0; + } + + &__title { + display: flex; + align-items: center; + height: $euiSizeL; + + .euiIcon { + margin-right: $euiSizeS; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx new file mode 100644 index 0000000000000..0af2327c6bbac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactElement } from 'react'; +import { shallow } from 'enzyme'; +import { EuiAccordion, EuiIcon } from '@elastic/eui'; + +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; + +describe('SummarySectionAccordion', () => { + const props = { + id: 'some-id', + status: 'success' as 'success' | 'error' | 'info', + title: 'Some title', + }; + + it('renders', () => { + const wrapper = shallow( + Hello World + ); + + expect(wrapper.type()).toEqual(EuiAccordion); + expect(wrapper.hasClass('documentCreationSummarySection')).toBe(true); + expect(wrapper.find(EuiAccordion).prop('children')).toEqual('Hello World'); + }); + + it('renders a title', () => { + const wrapper = shallow(); + const buttonContent = shallow(wrapper.find(EuiAccordion).prop('buttonContent') as ReactElement); + + expect(buttonContent.find('.documentCreationSummarySection__title').text()).toEqual( + 'Hello World' + ); + }); + + it('renders icons based on the status prop', () => { + const wrapper = shallow(); + const getIcon = () => { + const buttonContent = shallow( + wrapper.find(EuiAccordion).prop('buttonContent') as ReactElement + ); + return buttonContent.find(EuiIcon); + }; + + wrapper.setProps({ status: 'error' }); + expect(getIcon().prop('type')).toEqual('crossInACircleFilled'); + expect(getIcon().prop('color')).toEqual('danger'); + + wrapper.setProps({ status: 'success' }); + expect(getIcon().prop('type')).toEqual('checkInCircleFilled'); + expect(getIcon().prop('color')).toEqual('success'); + + wrapper.setProps({ status: 'info' }); + expect(getIcon().prop('type')).toEqual('iInCircle'); + expect(getIcon().prop('color')).toEqual('default'); + }); +}); + +describe('SummarySectionEmpty', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.hasClass('documentCreationSummarySection')).toBe(true); + expect(wrapper.find('.documentCreationSummarySection__title').text()).toEqual( + 'No new documents' + ); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('iInCircle'); + expect(wrapper.find(EuiIcon).prop('color')).toEqual('default'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx new file mode 100644 index 0000000000000..d50779e7ff003 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiAccordion, EuiIcon } from '@elastic/eui'; + +import './summary_section.scss'; + +const ICON_PROPS = { + error: { type: 'crossInACircleFilled', color: 'danger' }, + success: { type: 'checkInCircleFilled', color: 'success' }, + info: { type: 'iInCircle', color: 'default' }, +}; + +interface SummarySectionAccordionProps { + id: string; + status: 'success' | 'error' | 'info'; + title: string; +} +export const SummarySectionAccordion: React.FC = ({ + id, + status, + title, + children, +}) => { + return ( + + + {title} + + } + > + {children} + + ); +}; + +interface SummarySectionEmptyProps { + title: string; +} +export const SummarySectionEmpty: React.FC = ({ title }) => { + return ( +
+
+ + {title} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx new file mode 100644 index 0000000000000..86cea8ef23587 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiBadge } from '@elastic/eui'; +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; + +describe('InvalidDocumentsSummary', () => { + const mockDocument = { hello: 'world' }; + const mockExample = { document: mockDocument, errors: ['bad schema'] }; + + it('renders', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 1, + examples: [mockExample], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + '1 document with errors...' + ); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(0); + }); + + it('renders with MoreDocumentsText if more than 5 documents exist', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 100, + examples: [mockExample, mockExample, mockExample, mockExample, mockExample], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + '100 documents with errors...' + ); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(5); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText).prop('documents')).toEqual(95); + }); + + it('does not render if there are no invalid documents', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 0, + examples: [], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); + +describe('ValidDocumentsSummary', () => { + const mockDocument = { hello: 'world' }; + + it('renders', () => { + setMockValues({ + summary: { + validDocuments: { + total: 1, + examples: [mockDocument], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual('Added 1 document.'); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(0); + }); + + it('renders with MoreDocumentsText if more than 5 documents exist', () => { + setMockValues({ + summary: { + validDocuments: { + total: 7, + examples: [mockDocument, mockDocument, mockDocument, mockDocument, mockDocument], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual('Added 7 documents.'); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(5); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText).prop('documents')).toEqual(2); + }); + + it('renders SummarySectionEmpty if there are no valid documents', () => { + setMockValues({ + summary: { + validDocuments: { + total: 0, + examples: [], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new documents.'); + }); +}); + +describe('SchemaFieldsSummary', () => { + it('renders', () => { + setMockValues({ + summary: { + newSchemaFields: ['test'], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + "Added 1 field to the Engine's schema." + ); + expect(wrapper.find(EuiBadge)).toHaveLength(1); + }); + + it('renders multiple new schema fields', () => { + setMockValues({ + summary: { + newSchemaFields: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz'], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + "Added 6 fields to the Engine's schema." + ); + expect(wrapper.find(EuiBadge)).toHaveLength(6); + }); + + it('renders SummarySectionEmpty if there are no new schema fields', () => { + setMockValues({ + summary: { + newSchemaFields: [], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new schema fields.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx new file mode 100644 index 0000000000000..2a13622dfbc8e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; + +import { DocumentCreationLogic } from '../'; + +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +export const InvalidDocumentsSummary: React.FC = () => { + const { + summary: { invalidDocuments }, + } = useValues(DocumentCreationLogic); + + const hasInvalidDocuments = invalidDocuments.total > 0; + const unshownInvalidDocuments = invalidDocuments.total - invalidDocuments.examples.length; + + return hasInvalidDocuments ? ( + + {invalidDocuments.examples.map(({ document, errors }, index) => ( + + ))} + {unshownInvalidDocuments > 0 && } + + ) : null; +}; + +export const ValidDocumentsSummary: React.FC = () => { + const { + summary: { validDocuments }, + } = useValues(DocumentCreationLogic); + + const hasValidDocuments = validDocuments.total > 0; + const unshownValidDocuments = validDocuments.total - validDocuments.examples.length; + + return hasValidDocuments ? ( + + {validDocuments.examples.map((document, index) => ( + + ))} + {unshownValidDocuments > 0 && } + + ) : ( + + ); +}; + +export const SchemaFieldsSummary: React.FC = () => { + const { + summary: { newSchemaFields }, + } = useValues(DocumentCreationLogic); + + return newSchemaFields.length ? ( + + + {newSchemaFields.map((schemaField: string) => ( + + {schemaField} + + ))} + + + ) : ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx index f2799cde41e97..cc9a671e41e5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx @@ -16,6 +16,7 @@ import { PasteJsonText, UploadJsonFile, } from './creation_mode_components'; +import { Summary } from './creation_response_components'; import { DocumentCreationStep } from './types'; import { DocumentCreationFlyout, FlyoutContent } from './document_creation_flyout'; @@ -82,28 +83,11 @@ describe('DocumentCreationFlyout', () => { }); }); - describe('creation steps', () => { - it('renders an error page', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowError }); - const wrapper = shallow(); - - expect(wrapper.text()).toBe('DocumentCreationError'); // TODO: actual component - }); - - it('renders an error summary', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowErrorSummary }); - const wrapper = shallow(); - - expect(wrapper.text()).toBe('DocumentCreationSummary'); // TODO: actual component - }); - - it('renders a success summary', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSuccessSummary }); - const wrapper = shallow(); + it('renders a summary', () => { + setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSummary }); + const wrapper = shallow(); - // TODO: Figure out if the error and success summary should remain the same vs different components - expect(wrapper.text()).toBe('DocumentCreationSummary'); // TODO: actual component - }); + expect(wrapper.find(Summary)).toHaveLength(1); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx index ca52d14befb38..2dd00f0ded17d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx @@ -19,6 +19,7 @@ import { PasteJsonText, UploadJsonFile, } from './creation_mode_components'; +import { Summary } from './creation_response_components'; export const DocumentCreationFlyout: React.FC = () => { const { closeDocumentCreation } = useActions(DocumentCreationLogic); @@ -48,11 +49,7 @@ export const FlyoutContent: React.FC = () => { case 'file': return ; } - case DocumentCreationStep.ShowError: - return <>DocumentCreationError; - case DocumentCreationStep.ShowErrorSummary: - return <>DocumentCreationSummary; - case DocumentCreationStep.ShowSuccessSummary: - return <>DocumentCreationSummary; + case DocumentCreationStep.ShowSummary: + return ; } }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 1145d7853cb1a..bb0103b07b072 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -7,6 +7,20 @@ import { resetContext } from 'kea'; import dedent from 'dedent'; +jest.mock('./utils', () => ({ + readUploadedFileAsText: jest.fn(), +})); +import { readUploadedFileAsText } from './utils'; + +jest.mock('../../../shared/http', () => ({ + HttpLogic: { values: { http: { post: jest.fn() } } }, +})); +import { HttpLogic } from '../../../shared/http'; + +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'test-engine' } }, +})); + import { DOCUMENTS_API_JSON_EXAMPLE } from './constants'; import { DocumentCreationStep } from './types'; import { DocumentCreationLogic } from './'; @@ -18,11 +32,29 @@ describe('DocumentCreationLogic', () => { creationStep: DocumentCreationStep.AddDocuments, textInput: dedent(DOCUMENTS_API_JSON_EXAMPLE), fileInput: null, + isUploading: false, + warnings: [], + errors: [], + summary: {}, }; const mockFile = new File(['mockFile'], 'mockFile.json'); - const mount = () => { - resetContext({}); + const mount = (defaults?: object) => { + if (!defaults) { + resetContext({}); + } else { + resetContext({ + defaults: { + enterprise_search: { + app_search: { + document_creation_logic: { + ...defaults, + }, + }, + }, + }, + }); + } DocumentCreationLogic.mount(); }; @@ -120,17 +152,39 @@ describe('DocumentCreationLogic', () => { }); }); }); + + describe('errors & warnings', () => { + it('should be cleared', () => { + mount({ errors: ['error'], warnings: ['warnings'] }); + DocumentCreationLogic.actions.closeDocumentCreation(); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: [], + warnings: [], + }); + }); + }); + + describe('textInput & fileInput', () => { + it('should be reset to default values', () => { + mount({ textInput: 'test', fileInput: mockFile }); + DocumentCreationLogic.actions.closeDocumentCreation(); + + expect(DocumentCreationLogic.values).toEqual(DEFAULT_VALUES); + }); + }); }); describe('setCreationStep', () => { describe('creationStep', () => { it('should be set to the provided value', () => { mount(); - DocumentCreationLogic.actions.setCreationStep(DocumentCreationStep.ShowSuccessSummary); + DocumentCreationLogic.actions.setCreationStep(DocumentCreationStep.ShowSummary); expect(DocumentCreationLogic.values).toEqual({ ...DEFAULT_VALUES, - creationStep: 3, + creationStep: 2, }); }); }); @@ -163,5 +217,393 @@ describe('DocumentCreationLogic', () => { }); }); }); + + describe('setWarnings', () => { + describe('warnings', () => { + it('should be set to the provided value', () => { + mount(); + DocumentCreationLogic.actions.setWarnings(['warning!']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + warnings: ['warning!'], + }); + }); + }); + }); + + describe('setErrors', () => { + describe('errors', () => { + beforeAll(() => { + mount(); + }); + + it('should be set to the provided value', () => { + DocumentCreationLogic.actions.setErrors(['error 1', 'error 2']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error 1', 'error 2'], + }); + }); + + it('should gracefully array wrap single errors', () => { + DocumentCreationLogic.actions.setErrors('error'); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error'], + }); + }); + }); + + describe('isUploading', () => { + it('resets isUploading to false', () => { + mount({ isUploading: true }); + DocumentCreationLogic.actions.setErrors(['error']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error'], + isUploading: false, + }); + }); + }); + }); + + describe('setSummary', () => { + const mockSummary = { + errors: [], + validDocuments: { + total: 1, + examples: [{ foo: 'bar' }], + }, + invalidDocuments: { + total: 0, + examples: [], + }, + newSchemaFields: ['foo'], + }; + + describe('summary', () => { + it('should be set to the provided value', () => { + mount(); + DocumentCreationLogic.actions.setSummary(mockSummary); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + summary: mockSummary, + }); + }); + }); + + describe('isUploading', () => { + it('resets isUploading to false', () => { + mount({ isUploading: true }); + DocumentCreationLogic.actions.setSummary(mockSummary); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + summary: mockSummary, + isUploading: false, + }); + }); + }); + }); + + describe('onSubmitFile', () => { + describe('with a valid file', () => { + beforeAll(() => { + mount({ fileInput: mockFile }); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson').mockImplementation(); + }); + + it('should read the text in the file and submit it as JSON', async () => { + (readUploadedFileAsText as jest.Mock).mockReturnValue(Promise.resolve('some mock text')); + await DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.values.textInput).toEqual('some mock text'); + expect(DocumentCreationLogic.actions.onSubmitJson).toHaveBeenCalled(); + }); + + it('should set isUploading to true', () => { + DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.values.isUploading).toEqual(true); + }); + }); + + describe('with an invalid file', () => { + beforeAll(() => { + mount({ fileInput: mockFile }); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return an error', async () => { + (readUploadedFileAsText as jest.Mock).mockReturnValue(Promise.reject()); + await DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.actions.onSubmitJson).not.toHaveBeenCalled(); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Problem parsing file.', + ]); + }); + }); + + describe('without a file', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return an error', () => { + DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.actions.onSubmitJson).not.toHaveBeenCalled(); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith(['No file found.']); + }); + }); + }); + + describe('onSubmitJson', () => { + describe('with large JSON files', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setWarnings'); + }); + + it('should set a warning', () => { + jest.spyOn(global.Buffer, 'byteLength').mockImplementation(() => 55000000); // 55MB + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setWarnings).toHaveBeenCalledWith([ + expect.stringContaining("You're uploading an extremely large file"), + ]); + + jest.restoreAllMocks(); + }); + }); + + describe('with invalid JSON', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return malformed JSON errors', () => { + DocumentCreationLogic.actions.setTextInput('invalid JSON'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Unexpected token i in JSON at position 0', + ]); + expect(DocumentCreationLogic.actions.uploadDocuments).not.toHaveBeenCalled(); + }); + + it('should error on non-array/object JSON', () => { + DocumentCreationLogic.actions.setTextInput('null'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Document contents must be a valid JSON array or object.', + ]); + expect(DocumentCreationLogic.actions.uploadDocuments).not.toHaveBeenCalled(); + }); + }); + + describe('with valid JSON', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should accept an array of JSON objs', () => { + const mockJson = [{ foo: 'bar' }, { bar: 'baz' }]; + DocumentCreationLogic.actions.setTextInput('[{"foo":"bar"},{"bar":"baz"}]'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.uploadDocuments).toHaveBeenCalledWith({ + documents: mockJson, + }); + expect(DocumentCreationLogic.actions.setErrors).not.toHaveBeenCalled(); + }); + + it('should accept a single JSON obj', () => { + const mockJson = { foo: 'bar' }; + DocumentCreationLogic.actions.setTextInput('{"foo":"bar"}'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.uploadDocuments).toHaveBeenCalledWith({ + documents: [mockJson], + }); + expect(DocumentCreationLogic.actions.setErrors).not.toHaveBeenCalled(); + }); + }); + }); + + describe('uploadDocuments', () => { + describe('valid uploads', () => { + const mockValidDocuments = [{ foo: 'bar', bar: 'baz', qux: 'quux' }]; + const mockValidResponse = { + errors: [], + validDocuments: { total: 3, examples: mockValidDocuments }, + invalidDocuments: { total: 0, examples: [] }, + newSchemaFields: ['foo', 'bar', 'qux'], + }; + + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setSummary'); + jest.spyOn(DocumentCreationLogic.actions, 'setCreationStep'); + }); + + it('should set and show summary from the returned response', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.resolve(mockValidResponse) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: mockValidDocuments }); + await promise; + + expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith(mockValidResponse); + expect(DocumentCreationLogic.actions.setCreationStep).toHaveBeenCalledWith( + DocumentCreationStep.ShowSummary + ); + }); + }); + + describe('invalid uploads', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('handles API errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.reject({ + body: { + statusCode: 400, + error: 'Bad Request', + message: 'Invalid request payload JSON format', + }, + }) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( + '[400 Bad Request] Invalid request payload JSON format' + ); + }); + + it('handles client-side errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce(new Error()); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( + "Cannot read property 'total' of undefined" + ); + }); + + // NOTE: I can't seem to reproduce this in a production setting. + it('handles errors returned from the API', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.resolve({ + errors: ['JSON cannot be empty'], + }) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'JSON cannot be empty', + ]); + }); + }); + + describe('chunks large uploads', () => { + // Using an array of #s for speed, it doesn't really matter what the contents of the documents are for this test + const largeDocumentsArray = ([...Array(200).keys()] as unknown) as object[]; + + const mockFirstResponse = { + validDocuments: { total: 99, examples: largeDocumentsArray.slice(0, 98) }, + invalidDocuments: { + total: 1, + examples: [{ document: largeDocumentsArray[99], error: ['some error'] }], + }, + newSchemaFields: ['foo', 'bar'], + }; + const mockSecondResponse = { + validDocuments: { total: 99, examples: largeDocumentsArray.slice(1, 99) }, + invalidDocuments: { + total: 1, + examples: [{ document: largeDocumentsArray[0], error: ['another error'] }], + }, + newSchemaFields: ['bar', 'baz'], + }; + + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setSummary'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should correctly merge multiple API calls into a single summary obj', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock) + .mockReturnValueOnce(mockFirstResponse) + .mockReturnValueOnce(mockSecondResponse); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); + await promise; + + expect(http.post).toHaveBeenCalledTimes(2); + expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith({ + errors: [], + validDocuments: { + total: 198, + examples: largeDocumentsArray.slice(0, 5), + }, + invalidDocuments: { + total: 2, + examples: [ + { document: largeDocumentsArray[99], error: ['some error'] }, + { document: largeDocumentsArray[0], error: ['another error'] }, + ], + }, + newSchemaFields: ['foo', 'bar', 'baz'], + }); + }); + + it('should correctly merge response errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock) + .mockReturnValueOnce({ ...mockFirstResponse, errors: ['JSON cannot be empty'] }) + .mockReturnValueOnce({ ...mockSecondResponse, errors: ['Too large to render'] }); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); + await promise; + + expect(http.post).toHaveBeenCalledTimes(2); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'JSON cannot be empty', + 'Too large to render', + ]); + }); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts index 5b85e7f2ab54e..119baed74f684 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts @@ -6,9 +6,18 @@ import { kea, MakeLogicType } from 'kea'; import dedent from 'dedent'; +import { isPlainObject, chunk, uniq } from 'lodash'; -import { DOCUMENTS_API_JSON_EXAMPLE } from './constants'; -import { DocumentCreationMode, DocumentCreationStep } from './types'; +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { + DOCUMENTS_API_JSON_EXAMPLE, + DOCUMENT_CREATION_ERRORS, + DOCUMENT_CREATION_WARNINGS, +} from './constants'; +import { DocumentCreationMode, DocumentCreationStep, DocumentCreationSummary } from './types'; +import { readUploadedFileAsText } from './utils'; interface DocumentCreationValues { isDocumentCreationOpen: boolean; @@ -16,6 +25,10 @@ interface DocumentCreationValues { creationStep: DocumentCreationStep; textInput: string; fileInput: File | null; + isUploading: boolean; + warnings: string[]; + errors: string[]; + summary: DocumentCreationSummary; } interface DocumentCreationActions { @@ -25,6 +38,12 @@ interface DocumentCreationActions { setCreationStep(creationStep: DocumentCreationStep): { creationStep: DocumentCreationStep }; setTextInput(textInput: string): { textInput: string }; setFileInput(fileInput: File | null): { fileInput: File | null }; + setWarnings(warnings: string[]): { warnings: string[] }; + setErrors(errors: string[] | string): { errors: string[] }; + setSummary(summary: DocumentCreationSummary): { summary: DocumentCreationSummary }; + onSubmitFile(): void; + onSubmitJson(): void; + uploadDocuments(args: { documents: object[] }): { documents: object[] }; } export const DocumentCreationLogic = kea< @@ -38,6 +57,12 @@ export const DocumentCreationLogic = kea< setCreationStep: (creationStep) => ({ creationStep }), setTextInput: (textInput) => ({ textInput }), setFileInput: (fileInput) => ({ fileInput }), + setWarnings: (warnings) => ({ warnings }), + setErrors: (errors) => ({ errors }), + setSummary: (summary) => ({ summary }), + onSubmitJson: () => null, + onSubmitFile: () => null, + uploadDocuments: ({ documents }) => ({ documents }), }), reducers: () => ({ isDocumentCreationOpen: [ @@ -66,13 +91,134 @@ export const DocumentCreationLogic = kea< dedent(DOCUMENTS_API_JSON_EXAMPLE), { setTextInput: (_, { textInput }) => textInput, + closeDocumentCreation: () => dedent(DOCUMENTS_API_JSON_EXAMPLE), }, ], fileInput: [ null, { setFileInput: (_, { fileInput }) => fileInput, + closeDocumentCreation: () => null, + }, + ], + isUploading: [ + false, + { + onSubmitFile: () => true, + onSubmitJson: () => true, + setErrors: () => false, + setSummary: () => false, + }, + ], + warnings: [ + [], + { + onSubmitJson: () => [], + setWarnings: (_, { warnings }) => warnings, + closeDocumentCreation: () => [], + }, + ], + errors: [ + [], + { + onSubmitJson: () => [], + setErrors: (_, { errors }) => (Array.isArray(errors) ? errors : [errors]), + closeDocumentCreation: () => [], }, ], + summary: [ + {} as DocumentCreationSummary, + { + setSummary: (_, { summary }) => summary, + }, + ], + }), + listeners: ({ values, actions }) => ({ + onSubmitFile: async () => { + const { fileInput } = values; + + if (!fileInput) { + return actions.setErrors([DOCUMENT_CREATION_ERRORS.NO_FILE]); + } + try { + const textInput = await readUploadedFileAsText(fileInput); + actions.setTextInput(textInput); + actions.onSubmitJson(); + } catch { + actions.setErrors([DOCUMENT_CREATION_ERRORS.NO_VALID_FILE]); + } + }, + onSubmitJson: () => { + const { textInput } = values; + + const MAX_UPLOAD_BYTES = 50 * 1000000; // 50 MB + if (Buffer.byteLength(textInput) > MAX_UPLOAD_BYTES) { + actions.setWarnings([DOCUMENT_CREATION_WARNINGS.LARGE_FILE]); + } + + let documents; + try { + documents = JSON.parse(textInput); + } catch (error) { + return actions.setErrors([error.message]); + } + + if (Array.isArray(documents)) { + actions.uploadDocuments({ documents }); + } else if (isPlainObject(documents)) { + actions.uploadDocuments({ documents: [documents] }); + } else { + actions.setErrors([DOCUMENT_CREATION_ERRORS.NOT_VALID]); + } + }, + uploadDocuments: async ({ documents }) => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const CHUNK_SIZE = 100; + const MAX_EXAMPLES = 5; + + const promises = chunk(documents, CHUNK_SIZE).map((documentsChunk) => { + const body = JSON.stringify({ documents: documentsChunk }); + return http.post(`/api/app_search/engines/${engineName}/documents`, { body }); + }); + + try { + const responses = await Promise.all(promises); + const summary: DocumentCreationSummary = { + errors: [], + validDocuments: { total: 0, examples: [] }, + invalidDocuments: { total: 0, examples: [] }, + newSchemaFields: [], + }; + responses.forEach((response) => { + if (response.errors?.length > 0) { + summary.errors = uniq([...summary.errors, ...response.errors]); + return; + } + summary.validDocuments.total += response.validDocuments.total; + summary.invalidDocuments.total += response.invalidDocuments.total; + summary.validDocuments.examples = [ + ...summary.validDocuments.examples, + ...response.validDocuments.examples, + ].slice(0, MAX_EXAMPLES); + summary.invalidDocuments.examples = [ + ...summary.invalidDocuments.examples, + ...response.invalidDocuments.examples, + ].slice(0, MAX_EXAMPLES); + summary.newSchemaFields = uniq([...summary.newSchemaFields, ...response.newSchemaFields]); + }); + + if (summary.errors.length > 0) { + actions.setErrors(summary.errors); + } else { + actions.setSummary(summary); + actions.setCreationStep(DocumentCreationStep.ShowSummary); + } + } catch ({ body, message }) { + const errors = body ? `[${body.statusCode} ${body.error}] ${body.message}` : message; + actions.setErrors(errors); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts index d29bff162c197..ba641326f76b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts @@ -9,7 +9,21 @@ export type DocumentCreationMode = 'text' | 'file' | 'api'; export enum DocumentCreationStep { ShowCreationModes, AddDocuments, - ShowErrorSummary, - ShowSuccessSummary, - ShowError, + ShowSummary, +} + +export interface DocumentCreationSummary { + errors: string[]; + validDocuments: { + total: number; + examples: object[]; + }; + invalidDocuments: { + total: number; + examples: Array<{ + document: object; + errors: string[]; + }>; + }; + newSchemaFields: string[]; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts new file mode 100644 index 0000000000000..0df98c8d3030e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readUploadedFileAsText } from './utils'; + +describe('readUploadedFileAsText', () => { + it('reads a file as text', async () => { + const file = new File(['a mock file'], 'mockFile.json'); + const text = await readUploadedFileAsText(file); + expect(text).toEqual('a mock file'); + }); + + it('throws an error if the file cannot be read', async () => { + const badFile = ('causes an error' as unknown) as File; + await expect(readUploadedFileAsText(badFile)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts new file mode 100644 index 0000000000000..d2b207c51d22a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const readUploadedFileAsText = (fileInput: File): Promise => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = () => { + resolve(reader.result as string); + }; + try { + reader.readAsText(fileInput); + } catch { + reader.abort(); + reject(new Error()); + } + }); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts index d5fed4c6f97cb..5f57db40cd7e6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts @@ -6,7 +6,60 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerDocumentRoutes } from './documents'; +import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; + +describe('documents routes', () => { + describe('POST /api/app_search/engines/{engineName}/documents', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/documents', + payload: 'body', + }); + + registerDocumentsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { documents: [{ foo: 'bar' }] }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/documents/new', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { documents: [{ foo: 'bar' }] } }; + mockRouter.shouldValidate(request); + }); + + it('missing documents', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + + it('wrong document type', () => { + const request = { body: { documents: ['test'] } }; + mockRouter.shouldThrow(request); + }); + + it('non-array documents type', () => { + const request = { body: { documents: { foo: 'bar' } } }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); describe('document routes', () => { describe('GET /api/app_search/engines/{engineName}/documents/{documentId}', () => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts index a2f4b323a91aa..60cd64b32479c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts @@ -8,6 +8,30 @@ import { schema } from '@kbn/config-schema'; import { RouteDependencies } from '../../plugin'; +export function registerDocumentsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/engines/{engineName}/documents', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + documents: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${request.params.engineName}/documents/new`, + })(context, request, response); + } + ); +} + export function registerDocumentRoutes({ router, enterpriseSearchRequestHandler, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index f64e45c656fa1..67dcbfdc4f4d5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,11 +9,12 @@ import { RouteDependencies } from '../../plugin'; import { registerEnginesRoutes } from './engines'; import { registerCredentialsRoutes } from './credentials'; import { registerSettingsRoutes } from './settings'; -import { registerDocumentRoutes } from './documents'; +import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerEnginesRoutes(dependencies); registerCredentialsRoutes(dependencies); registerSettingsRoutes(dependencies); + registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); };