[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:
Constance 2021-01-04 11:09:43 -08:00 committed by GitHub
parent 4415e548b5
commit 5241603297
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1687 additions and 57 deletions

View file

@ -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

View file

@ -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);
});
});
});

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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>

View file

@ -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);
});
});

View file

@ -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>
)}
</>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { Errors } from './errors';
export { Summary } from './summary';

View file

@ -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);
});
});
});

View file

@ -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>
);
};

View file

@ -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.');
});
});

View file

@ -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>
);
};

View file

@ -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;
}
}
}

View file

@ -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');
});
});

View file

@ -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>
);
};

View file

@ -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.');
});
});

View file

@ -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.' }
)}
/>
);
};

View file

@ -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);
});
});
});

View file

@ -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 />;
}
};

View file

@ -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',
]);
});
});
});
});
});

View file

@ -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);
}
},
}),
});

View file

@ -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[];
}

View file

@ -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();
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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());
}
});
};

View file

@ -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}', () => {

View file

@ -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,

View file

@ -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);
};