[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>
This commit is contained in:
parent
4415e548b5
commit
5241603297
|
@ -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
|
||||
|
|
|
@ -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(<FlyoutBody />);
|
||||
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(<FlyoutFooter />);
|
||||
|
||||
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(<FlyoutFooter />);
|
||||
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(<FlyoutFooter />);
|
||||
expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false);
|
||||
|
||||
setMockValues({ ...values, isUploading: true });
|
||||
rerender(wrapper);
|
||||
expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlyoutBody banner={<Errors />}>
|
||||
<EuiText color="subdued">
|
||||
<p>
|
||||
{i18n.translate(
|
||||
|
@ -76,6 +77,7 @@ export const FlyoutBody: React.FC = () => {
|
|||
<EuiTextArea
|
||||
value={textInput}
|
||||
onChange={(e) => 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 (
|
||||
<EuiFlyoutFooter>
|
||||
|
@ -99,7 +101,7 @@ export const FlyoutFooter: React.FC = () => {
|
|||
<EuiButtonEmpty onClick={closeDocumentCreation}>{FLYOUT_CANCEL_BUTTON}</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill isDisabled={!textInput.length}>
|
||||
<EuiButton fill onClick={onSubmitJson} isLoading={isUploading} isDisabled={!textInput}>
|
||||
{FLYOUT_CONTINUE_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -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(<FlyoutBody />);
|
||||
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(<FlyoutBody />);
|
||||
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(<FlyoutFooter />);
|
||||
|
||||
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(<FlyoutFooter />);
|
||||
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(<FlyoutFooter />);
|
||||
expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false);
|
||||
|
||||
setMockValues({ ...values, isUploading: true });
|
||||
rerender(wrapper);
|
||||
expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlyoutBody banner={<Errors />}>
|
||||
<EuiText color="subdued">
|
||||
<p>
|
||||
{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}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlyoutFooter: React.FC = () => {
|
||||
const { fileInput } = useValues(DocumentCreationLogic);
|
||||
const { closeDocumentCreation } = useActions(DocumentCreationLogic);
|
||||
const { fileInput, isUploading } = useValues(DocumentCreationLogic);
|
||||
const { onSubmitFile, closeDocumentCreation } = useActions(DocumentCreationLogic);
|
||||
|
||||
return (
|
||||
<EuiFlyoutFooter>
|
||||
|
@ -96,7 +95,7 @@ export const FlyoutFooter: React.FC = () => {
|
|||
<EuiButtonEmpty onClick={closeDocumentCreation}>{FLYOUT_CANCEL_BUTTON}</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill isDisabled={!fileInput}>
|
||||
<EuiButton fill onClick={onSubmitFile} isLoading={isUploading} isDisabled={!fileInput}>
|
||||
{FLYOUT_CONTINUE_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -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(<Errors />);
|
||||
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders errors', () => {
|
||||
setMockValues({ errors: ['error 1', 'error 2'], warnings: [] });
|
||||
const wrapper = shallow(<Errors />);
|
||||
|
||||
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(<Errors />);
|
||||
|
||||
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(<Errors />);
|
||||
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(2);
|
||||
});
|
||||
});
|
|
@ -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 && (
|
||||
<EuiCallOut color="danger" iconType="alert" title={DOCUMENT_CREATION_ERRORS.TITLE}>
|
||||
{errors.map((message, index) => (
|
||||
<p key={index}>{message}</p>
|
||||
))}
|
||||
</EuiCallOut>
|
||||
)}
|
||||
{warnings.length > 0 && (
|
||||
<EuiCallOut color="warning" iconType="alert" title={DOCUMENT_CREATION_WARNINGS.TITLE}>
|
||||
{warnings.map((message, index) => (
|
||||
<p key={index}>{message}</p>
|
||||
))}
|
||||
</EuiCallOut>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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(<Summary />);
|
||||
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(<FlyoutHeader />);
|
||||
expect(wrapper.find('h2').text()).toEqual('Indexing summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlyoutBody', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<FlyoutBody />);
|
||||
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(<FlyoutBody />);
|
||||
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(<FlyoutFooter />);
|
||||
|
||||
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(<FlyoutFooter />);
|
||||
|
||||
wrapper.find(EuiButton).last().simulate('click');
|
||||
expect(actions.setCreationStep).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<>
|
||||
<FlyoutHeader />
|
||||
<FlyoutBody />
|
||||
<FlyoutFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlyoutHeader: React.FC = () => {
|
||||
return (
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2 id={FLYOUT_ARIA_LABEL_ID}>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.showSummary.title', {
|
||||
defaultMessage: 'Indexing summary',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlyoutBody: React.FC = () => {
|
||||
const { summary } = useValues(DocumentCreationLogic);
|
||||
const hasInvalidDocuments = summary.invalidDocuments.total > 0;
|
||||
const invalidDocumentsBanner = (
|
||||
<EuiCallOut color="danger" iconType="alert" title={DOCUMENT_CREATION_ERRORS.TITLE} />
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlyoutBody banner={hasInvalidDocuments && invalidDocumentsBanner}>
|
||||
<InvalidDocumentsSummary />
|
||||
<ValidDocumentsSummary />
|
||||
<SchemaFieldsSummary />
|
||||
</EuiFlyoutBody>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlyoutFooter: React.FC = () => {
|
||||
const { setCreationStep, closeDocumentCreation } = useActions(DocumentCreationLogic);
|
||||
const { summary } = useValues(DocumentCreationLogic);
|
||||
const hasInvalidDocuments = summary.invalidDocuments.total > 0;
|
||||
|
||||
return (
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton onClick={closeDocumentCreation}>{FLYOUT_CLOSE_BUTTON}</EuiButton>
|
||||
</EuiFlexItem>
|
||||
{hasInvalidDocuments && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill onClick={() => setCreationStep(DocumentCreationStep.AddDocuments)}>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.fixErrors',
|
||||
{ defaultMessage: 'Fix errors' }
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
);
|
||||
};
|
|
@ -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(<ExampleDocumentJson document={exampleDocument} />);
|
||||
|
||||
expect(wrapper.find(EuiCodeBlock).prop('children')).toEqual(expectedJson);
|
||||
expect(wrapper.find(EuiCallOut)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders invalid documents with error callouts', () => {
|
||||
const wrapper = shallow(
|
||||
<ExampleDocumentJson document={exampleDocument} errors={['Bad JSON error', 'Schema error']} />
|
||||
);
|
||||
|
||||
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(<MoreDocumentsText documents={100} />);
|
||||
expect(wrapper.find('p').text()).toEqual('and 100 other documents.');
|
||||
|
||||
wrapper.setProps({ documents: 1 });
|
||||
expect(wrapper.find('p').text()).toEqual('and 1 other document.');
|
||||
});
|
||||
});
|
|
@ -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<ExampleDocumentJsonProps> = ({ document, errors }) => {
|
||||
return (
|
||||
<>
|
||||
{errors && (
|
||||
<>
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.documentNotIndexed',
|
||||
{ defaultMessage: 'This document was not indexed!' }
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
{errors.map((errorMessage, index) => (
|
||||
<Fragment key={index}>
|
||||
<EuiCallOut color="danger" size="s" title={errorMessage} />
|
||||
<EuiSpacer size="s" />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<EuiCodeBlock language="json" paddingSize="m" overflowHeight={200}>
|
||||
{JSON.stringify(document, null, 2)}
|
||||
</EuiCodeBlock>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface MoreDocumentsTextProps {
|
||||
documents: number;
|
||||
}
|
||||
export const MoreDocumentsText: React.FC<MoreDocumentsTextProps> = ({ documents }) => {
|
||||
return (
|
||||
<EuiText>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.otherDocuments',
|
||||
{
|
||||
defaultMessage:
|
||||
'and {documents, number} other {documents, plural, one {document} other {documents}}.',
|
||||
values: { documents },
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</EuiText>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
<SummarySectionAccordion {...props}>Hello World</SummarySectionAccordion>
|
||||
);
|
||||
|
||||
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(<SummarySectionAccordion {...props} title="Hello World" />);
|
||||
const buttonContent = shallow(wrapper.find(EuiAccordion).prop('buttonContent') as ReactElement);
|
||||
|
||||
expect(buttonContent.find('.documentCreationSummarySection__title').text()).toEqual(
|
||||
'<EuiIcon />Hello World'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders icons based on the status prop', () => {
|
||||
const wrapper = shallow(<SummarySectionAccordion {...props} />);
|
||||
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(<SummarySectionEmpty title="No new documents" />);
|
||||
|
||||
expect(wrapper.hasClass('documentCreationSummarySection')).toBe(true);
|
||||
expect(wrapper.find('.documentCreationSummarySection__title').text()).toEqual(
|
||||
'<EuiIcon />No new documents'
|
||||
);
|
||||
expect(wrapper.find(EuiIcon).prop('type')).toEqual('iInCircle');
|
||||
expect(wrapper.find(EuiIcon).prop('color')).toEqual('default');
|
||||
});
|
||||
});
|
|
@ -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<SummarySectionAccordionProps> = ({
|
||||
id,
|
||||
status,
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<EuiAccordion
|
||||
id={id}
|
||||
className="documentCreationSummarySection"
|
||||
arrowDisplay="right"
|
||||
paddingSize="m"
|
||||
buttonContent={
|
||||
<div className="documentCreationSummarySection__title">
|
||||
<EuiIcon type={ICON_PROPS[status].type} color={ICON_PROPS[status].color} />
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummarySectionEmptyProps {
|
||||
title: string;
|
||||
}
|
||||
export const SummarySectionEmpty: React.FC<SummarySectionEmptyProps> = ({ title }) => {
|
||||
return (
|
||||
<div className="documentCreationSummarySection">
|
||||
<div className="documentCreationSummarySection__title">
|
||||
<EuiIcon {...ICON_PROPS.info} />
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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(<InvalidDocumentsSummary />);
|
||||
|
||||
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(<InvalidDocumentsSummary />);
|
||||
|
||||
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(<InvalidDocumentsSummary />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidDocumentsSummary', () => {
|
||||
const mockDocument = { hello: 'world' };
|
||||
|
||||
it('renders', () => {
|
||||
setMockValues({
|
||||
summary: {
|
||||
validDocuments: {
|
||||
total: 1,
|
||||
examples: [mockDocument],
|
||||
},
|
||||
},
|
||||
});
|
||||
const wrapper = shallow(<ValidDocumentsSummary />);
|
||||
|
||||
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(<ValidDocumentsSummary />);
|
||||
|
||||
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(<ValidDocumentsSummary />);
|
||||
|
||||
expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new documents.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchemaFieldsSummary', () => {
|
||||
it('renders', () => {
|
||||
setMockValues({
|
||||
summary: {
|
||||
newSchemaFields: ['test'],
|
||||
},
|
||||
});
|
||||
const wrapper = shallow(<SchemaFieldsSummary />);
|
||||
|
||||
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(<SchemaFieldsSummary />);
|
||||
|
||||
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(<SchemaFieldsSummary />);
|
||||
|
||||
expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new schema fields.');
|
||||
});
|
||||
});
|
|
@ -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 ? (
|
||||
<SummarySectionAccordion
|
||||
id="invalidDocuments"
|
||||
status="error"
|
||||
title={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.invalidDocuments',
|
||||
{
|
||||
defaultMessage:
|
||||
'{invalidDocuments, number} {invalidDocuments, plural, one {document} other {documents}} with errors...',
|
||||
values: { invalidDocuments: invalidDocuments.total },
|
||||
}
|
||||
)}
|
||||
>
|
||||
{invalidDocuments.examples.map(({ document, errors }, index) => (
|
||||
<ExampleDocumentJson document={document} errors={errors} key={index} />
|
||||
))}
|
||||
{unshownInvalidDocuments > 0 && <MoreDocumentsText documents={unshownInvalidDocuments} />}
|
||||
</SummarySectionAccordion>
|
||||
) : 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 ? (
|
||||
<SummarySectionAccordion
|
||||
id="newDocuments"
|
||||
status="success"
|
||||
title={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.newDocuments',
|
||||
{
|
||||
defaultMessage:
|
||||
'Added {newDocuments, number} {newDocuments, plural, one {document} other {documents}}.',
|
||||
values: { newDocuments: validDocuments.total },
|
||||
}
|
||||
)}
|
||||
>
|
||||
{validDocuments.examples.map((document, index) => (
|
||||
<ExampleDocumentJson document={document} key={index} />
|
||||
))}
|
||||
{unshownValidDocuments > 0 && <MoreDocumentsText documents={unshownValidDocuments} />}
|
||||
</SummarySectionAccordion>
|
||||
) : (
|
||||
<SummarySectionEmpty
|
||||
title={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.noNewDocuments',
|
||||
{ defaultMessage: 'No new documents.' }
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SchemaFieldsSummary: React.FC = () => {
|
||||
const {
|
||||
summary: { newSchemaFields },
|
||||
} = useValues(DocumentCreationLogic);
|
||||
|
||||
return newSchemaFields.length ? (
|
||||
<SummarySectionAccordion
|
||||
id="newSchemaFields"
|
||||
status="info"
|
||||
title={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.newSchemaFields',
|
||||
{
|
||||
defaultMessage:
|
||||
"Added {newFields, number} {newFields, plural, one {field} other {fields}} to the Engine's schema.",
|
||||
values: { newFields: newSchemaFields.length },
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="s">
|
||||
{newSchemaFields.map((schemaField: string) => (
|
||||
<EuiFlexItem grow={false} key={schemaField}>
|
||||
<EuiBadge>{schemaField}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</SummarySectionAccordion>
|
||||
) : (
|
||||
<SummarySectionEmpty
|
||||
title={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.noNewSchemaFields',
|
||||
{ defaultMessage: 'No new schema fields.' }
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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(<FlyoutContent />);
|
||||
it('renders a summary', () => {
|
||||
setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSummary });
|
||||
const wrapper = shallow(<FlyoutContent />);
|
||||
|
||||
expect(wrapper.text()).toBe('DocumentCreationError'); // TODO: actual component
|
||||
});
|
||||
|
||||
it('renders an error summary', () => {
|
||||
setMockValues({ ...values, creationStep: DocumentCreationStep.ShowErrorSummary });
|
||||
const wrapper = shallow(<FlyoutContent />);
|
||||
|
||||
expect(wrapper.text()).toBe('DocumentCreationSummary'); // TODO: actual component
|
||||
});
|
||||
|
||||
it('renders a success summary', () => {
|
||||
setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSuccessSummary });
|
||||
const wrapper = shallow(<FlyoutContent />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 <UploadJsonFile />;
|
||||
}
|
||||
case DocumentCreationStep.ShowError:
|
||||
return <>DocumentCreationError</>;
|
||||
case DocumentCreationStep.ShowErrorSummary:
|
||||
return <>DocumentCreationSummary</>;
|
||||
case DocumentCreationStep.ShowSuccessSummary:
|
||||
return <>DocumentCreationSummary</>;
|
||||
case DocumentCreationStep.ShowSummary:
|
||||
return <Summary />;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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<string> => {
|
||||
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());
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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}', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue