diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index c380032bd9482..d9a0ac4115389 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -44,6 +44,11 @@ const appServices = { api: apiService, notifications: notificationServiceMock.createSetupContract(), history, + urlGenerators: { + getUrlGenerator: jest.fn().mockReturnValue({ + createUrl: jest.fn(), + }), + }, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts index 541a6853a99b3..c89b07ae0192f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts @@ -21,8 +21,20 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setFetchDocumentsResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('GET', '/api/ingest_pipelines/documents/:index/:id', [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + return { setSimulatePipelineResponse, + setFetchDocumentsResponse, }; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx index f4c89d7a1058a..215ef63d9782e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx @@ -94,6 +94,11 @@ const appServices = { notifications: notificationServiceMock.createSetupContract(), history, uiSettings: {}, + urlGenerators: { + getUrlGenerator: jest.fn().mockReturnValue({ + createUrl: jest.fn(), + }), + }, }; const testBedSetup = registerTestBed( @@ -180,6 +185,20 @@ const createActions = (testBed: TestBed) => { }); component.update(); }, + + async toggleDocumentsAccordion() { + await act(async () => { + find('addDocumentsAccordion').simulate('click'); + }); + component.update(); + }, + + async clickAddDocumentButton() { + await act(async () => { + find('addDocumentButton').simulate('click'); + }); + component.update(); + }, }; }; @@ -229,4 +248,8 @@ type TestSubject = | 'configurationTab' | 'outputTab' | 'processorOutputTabContent' + | 'addDocumentsAccordion' + | 'addDocumentButton' + | 'addDocumentError' + | 'addDocumentSuccess' | string; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx index e5118a6e465af..47f05602799e4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx @@ -141,10 +141,9 @@ describe('Test pipeline', () => { const { actions, find, exists } = testBed; const error = { - status: 400, - error: 'Bad Request', - message: - '"[parse_exception] [_source] required property is missing, with { property_name="_source" }"', + status: 500, + error: 'Internal server error', + message: 'Internal server error', }; httpRequestsMockHelpers.setSimulatePipelineResponse(undefined, { body: error }); @@ -153,13 +152,90 @@ describe('Test pipeline', () => { actions.clickAddDocumentsButton(); // Add invalid sample documents array and run the pipeline - actions.addDocumentsJson(JSON.stringify([{}])); + actions.addDocumentsJson( + JSON.stringify([ + { + _index: 'test', + _id: '1', + _version: 1, + _seq_no: 0, + _primary_term: 1, + _source: { + name: 'John Doe', + }, + }, + ]) + ); await actions.clickRunPipelineButton(); // Verify error rendered expect(exists('pipelineExecutionError')).toBe(true); expect(find('pipelineExecutionError').text()).toContain(error.message); }); + + describe('Add indexed documents', () => { + test('should successfully add an indexed document', async () => { + const { actions, form, exists } = testBed; + + const { _index: index, _id: documentId } = DOCUMENTS[0]; + + httpRequestsMockHelpers.setFetchDocumentsResponse(DOCUMENTS[0]); + + // Open flyout + actions.clickAddDocumentsButton(); + + // Open documents accordion, click run without required fields, and verify error messages + await actions.toggleDocumentsAccordion(); + await actions.clickAddDocumentButton(); + expect(form.getErrorsMessages()).toEqual([ + 'An index name is required.', + 'A document ID is required.', + ]); + + // Add required fields, and click run + form.setInputValue('indexField.input', index); + form.setInputValue('idField.input', documentId); + await actions.clickAddDocumentButton(); + + // Verify request + const latestRequest = server.requests[server.requests.length - 1]; + expect(latestRequest.status).toEqual(200); + expect(latestRequest.url).toEqual(`/api/ingest_pipelines/documents/${index}/${documentId}`); + // Verify success callout + expect(exists('addDocumentSuccess')).toBe(true); + }); + + test('should surface API errors from the request', async () => { + const { actions, form, exists, find } = testBed; + + const nonExistentDoc = { + index: 'foo', + id: '1', + }; + + const error = { + status: 404, + error: 'Not found', + message: '[index_not_found_exception] no such index', + }; + + httpRequestsMockHelpers.setFetchDocumentsResponse(undefined, { body: error }); + + // Open flyout + actions.clickAddDocumentsButton(); + + // Open documents accordion, add required fields, and click run + await actions.toggleDocumentsAccordion(); + form.setInputValue('indexField.input', nonExistentDoc.index); + form.setInputValue('idField.input', nonExistentDoc.id); + await actions.clickAddDocumentButton(); + + // Verify error rendered + expect(exists('addDocumentError')).toBe(true); + expect(exists('addDocumentSuccess')).toBe(false); + expect(find('addDocumentError').text()).toContain(error.message); + }); + }); }); describe('Processors', () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx index b49eea5b59ab0..23dda55db41f8 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx @@ -12,6 +12,7 @@ import { useTestPipelineContext } from '../../context'; import { serialize } from '../../serialize'; import { DeserializeResult } from '../../deserialize'; import { Document } from '../../types'; +import { useIsMounted } from '../../use_is_mounted'; import { TestPipelineFlyout as ViewComponent } from './test_pipeline_flyout'; import { TestPipelineFlyoutTab } from './test_pipeline_flyout_tabs'; @@ -34,6 +35,7 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ processors, }) => { const { services } = useKibana(); + const isMounted = useIsMounted(); const { testPipelineData, @@ -74,6 +76,10 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ pipeline: { ...serializedProcessors }, }); + if (!isMounted.current) { + return { isSuccessful: false }; + } + setIsRunningTest(false); if (error) { @@ -123,6 +129,7 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ return { isSuccessful: true }; }, [ + isMounted, processors, services.api, services.notifications.toasts, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx index 46271a6bce51c..d4895f8805531 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx @@ -16,7 +16,7 @@ import { EuiCallOut, } from '@elastic/eui'; -import { Form, FormHook } from '../../../../../shared_imports'; +import { FormHook } from '../../../../../shared_imports'; import { Document } from '../../types'; import { Tabs, TestPipelineFlyoutTab, OutputTab, DocumentsTab } from './test_pipeline_flyout_tabs'; @@ -71,19 +71,11 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ } else { // default to "Documents" tab tabContent = ( -
- - + validateAndTestPipeline={validateAndTestPipeline} + isRunningTest={isRunningTest} + /> ); } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_document_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_document_form.tsx new file mode 100644 index 0000000000000..340cf1af92300 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_document_form.tsx @@ -0,0 +1,209 @@ +/* + * 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, { useState, FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, + EuiSpacer, + EuiText, + EuiIcon, +} from '@elastic/eui'; + +import { + getUseField, + Field, + useKibana, + useForm, + Form, + TextField, + fieldValidators, + FieldConfig, +} from '../../../../../../shared_imports'; +import { useIsMounted } from '../../../use_is_mounted'; +import { Document } from '../../../types'; + +const UseField = getUseField({ component: Field }); + +const { emptyField } = fieldValidators; + +const i18nTexts = { + addDocumentButton: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.addDocumentButtonLabel', + { + defaultMessage: 'Add document', + } + ), + addDocumentErrorMessage: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.addDocumentErrorMessage', + { + defaultMessage: 'Error adding document', + } + ), + addDocumentSuccessMessage: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.addDocumentSuccessMessage', + { + defaultMessage: 'Document added', + } + ), + indexField: { + fieldLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.indexFieldLabel', + { + defaultMessage: 'Index', + } + ), + validationMessage: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.indexRequiredErrorMessage', + { + defaultMessage: 'An index name is required.', + } + ), + }, + idField: { + fieldLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.addDocuments.idFieldLabel', { + defaultMessage: 'Document ID', + }), + validationMessage: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocuments.idRequiredErrorMessage', + { + defaultMessage: 'A document ID is required.', + } + ), + }, +}; + +const fieldsConfig: Record = { + index: { + label: i18nTexts.indexField.fieldLabel, + validations: [ + { + validator: emptyField(i18nTexts.indexField.validationMessage), + }, + ], + }, + id: { + label: i18nTexts.idField.fieldLabel, + validations: [ + { + validator: emptyField(i18nTexts.idField.validationMessage), + }, + ], + }, +}; + +interface Props { + onAddDocuments: (document: Document) => void; +} + +export const AddDocumentForm: FunctionComponent = ({ onAddDocuments }) => { + const { services } = useKibana(); + const isMounted = useIsMounted(); + + const [isLoadingDocument, setIsLoadingDocument] = useState(false); + const [documentError, setDocumentError] = useState(undefined); + const [isDocumentAdded, setIsDocumentAdded] = useState(false); + + const { form } = useForm({ defaultValue: { index: '', id: '' } }); + + const submitForm = async (e: React.FormEvent) => { + const { isValid, data } = await form.submit(); + + const { id, index } = data; + + if (isValid) { + setIsLoadingDocument(true); + setDocumentError(undefined); + setIsDocumentAdded(false); + + const { error, data: document } = await services.api.loadDocument(index, id); + + if (!isMounted.current) { + return; + } + + setIsLoadingDocument(false); + + if (error) { + setDocumentError(error); + return; + } + + setIsDocumentAdded(true); + onAddDocuments(document); + } + }; + + return ( +
+ {documentError && ( + <> + +

{documentError.message}

+
+ + + + )} + + + + + + + + + + + {i18nTexts.addDocumentButton} + + + + {isDocumentAdded && ( + + + + + + + + {i18nTexts.addDocumentSuccessMessage} + + + + + )} + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.scss new file mode 100644 index 0000000000000..2bf234fab2ece --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.scss @@ -0,0 +1,4 @@ +.addDocumentsAccordion { + background-color: $euiColorLightestShade; + padding: $euiSizeM; +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.tsx new file mode 100644 index 0000000000000..88ced6e9e94dd --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/add_documents_accordion.tsx @@ -0,0 +1,111 @@ +/* + * 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, { FunctionComponent, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiAccordion, EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; +import { UrlGeneratorsDefinition } from 'src/plugins/share/public'; + +import { useKibana } from '../../../../../../../shared_imports'; +import { useIsMounted } from '../../../../use_is_mounted'; +import { AddDocumentForm } from '../add_document_form'; + +import './add_documents_accordion.scss'; + +const DISCOVER_URL_GENERATOR_ID = 'DISCOVER_APP_URL_GENERATOR'; + +const i18nTexts = { + addDocumentsButton: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.addDocumentsAccordion.addDocumentsButtonLabel', + { + defaultMessage: 'Add documents from index', + } + ), +}; + +interface Props { + onAddDocuments: (document: any) => void; +} + +export const AddDocumentsAccordion: FunctionComponent = ({ onAddDocuments }) => { + const { services } = useKibana(); + const isMounted = useIsMounted(); + const [discoverLink, setDiscoverLink] = useState(undefined); + + useEffect(() => { + const getDiscoverUrl = async (): Promise => { + let isDeprecated: UrlGeneratorsDefinition['isDeprecated']; + let createUrl: UrlGeneratorsDefinition['createUrl']; + + // This try/catch may not be necessary once + // https://github.com/elastic/kibana/issues/78344 is addressed + try { + ({ isDeprecated, createUrl } = services.urlGenerators.getUrlGenerator( + DISCOVER_URL_GENERATOR_ID + )); + } catch (e) { + // Discover plugin is not enabled + setDiscoverLink(undefined); + return; + } + + if (isDeprecated) { + setDiscoverLink(undefined); + return; + } + + const discoverUrl = await createUrl({ indexPatternId: undefined }); + + if (isMounted.current) { + setDiscoverLink(discoverUrl); + } + }; + + getDiscoverUrl(); + }, [isMounted, services.urlGenerators]); + + return ( + +
+ +

+ + {discoverLink && ( + <> + {' '} + + Discover + + ), + }} + /> + + )} +

+
+ + + + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/index.ts new file mode 100644 index 0000000000000..cb00ec640b5a6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/add_documents_accordion/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { AddDocumentsAccordion } from './add_documents_accordion'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/documents_schema.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/documents_schema.tsx index e8ac223d56ed9..d0e0596375cb2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/documents_schema.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/documents_schema.tsx @@ -82,6 +82,30 @@ export const documentsSchema: FormSchema = { } }, }, + { + validator: ({ value }: ValidationFuncArg) => { + const parsedJSON = JSON.parse(value); + + const isMissingSourceField = parsedJSON.find((document: { _source?: object }) => { + if (!document._source) { + return true; + } + + return false; + }); + + if (isMissingSourceField) { + return { + message: i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.sourceFieldRequiredError', + { + defaultMessage: 'Documents require a _source field.', + } + ), + }; + } + }, + }, ], }, }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/tab_documents.tsx index b2326644340a7..6fd340054d2a4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout_tabs/tab_documents.tsx @@ -4,98 +4,131 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButton, EuiLink } from '@elastic/eui'; -import { getUseField, Field, JsonEditorField, useKibana } from '../../../../../../shared_imports'; +import { + getUseField, + Field, + JsonEditorField, + useKibana, + useFormData, + FormHook, + Form, +} from '../../../../../../shared_imports'; + +import { AddDocumentsAccordion } from './add_documents_accordion'; const UseField = getUseField({ component: Field }); interface Props { validateAndTestPipeline: () => Promise; isRunningTest: boolean; - isSubmitButtonDisabled: boolean; + form: FormHook; } -export const DocumentsTab: React.FunctionComponent = ({ +export const DocumentsTab: FunctionComponent = ({ validateAndTestPipeline, - isSubmitButtonDisabled, isRunningTest, + form, }) => { const { services } = useKibana(); + const [, formatData] = useFormData({ form }); + + const onAddDocumentHandler = useCallback( + (document) => { + const { documents: existingDocuments = [] } = formatData(); + + form.reset({ defaultValue: { documents: [...existingDocuments, document] } }); + }, + [form, formatData] + ); + return ( -
- -

- - {i18n.translate( - 'xpack.ingestPipelines.testPipelineFlyout.documentsTab.simulateDocumentionLink', - { - defaultMessage: 'Learn more.', - } - )} - +

+
+ +

+ + {i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsTab.simulateDocumentionLink', + { + defaultMessage: 'Learn more.', + } + )} + + ), + }} + /> +

+
+ + + + + + + + {/* Documents editor */} + -

- - - - - {/* Documents editor */} - - - - - - {isRunningTest ? ( - - ) : ( - - )} - -
+ }, + }} + /> + + + + + {isRunningTest ? ( + + ) : ( + + )} + +
+ ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx index 9aafeafa10b27..314964f808e44 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx @@ -15,6 +15,7 @@ import { } from '../deserialize'; import { serialize } from '../serialize'; import { Document } from '../types'; +import { useIsMounted } from '../use_is_mounted'; export interface TestPipelineData { config: { @@ -127,6 +128,7 @@ export const reducer: Reducer = (state, action) => { export const TestPipelineContextProvider = ({ children }: { children: React.ReactNode }) => { const [state, dispatch] = useReducer(reducer, DEFAULT_TEST_PIPELINE_CONTEXT.testPipelineData); const { services } = useKibana(); + const isMounted = useIsMounted(); const updateTestOutputPerProcessor = useCallback( async (documents: Document[] | undefined, processors: DeserializeResult) => { @@ -152,6 +154,10 @@ export const TestPipelineContextProvider = ({ children }: { children: React.Reac pipeline: { ...serializedProcessorsWithTag }, }); + if (!isMounted.current) { + return; + } + if (error) { dispatch({ type: 'updateOutputPerProcessor', @@ -180,7 +186,7 @@ export const TestPipelineContextProvider = ({ children }: { children: React.Reac }, }); }, - [services.api, services.notifications.toasts] + [isMounted, services.api, services.notifications.toasts] ); return ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts new file mode 100644 index 0000000000000..c0df15e8a7fb7 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useRef } from 'react'; + +export const useIsMounted = () => { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + return isMounted; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index 6ffebd1854b78..0a71babc53315 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -9,6 +9,7 @@ import React, { ReactNode } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { NotificationsSetup, IUiSettingsClient } from 'kibana/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; +import { SharePluginStart } from 'src/plugins/share/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { API_BASE_PATH } from '../../common/constants'; @@ -26,6 +27,7 @@ export interface AppServices { notifications: NotificationsSetup; history: ManagementAppMountParams['history']; uiSettings: IUiSettingsClient; + urlGenerators: SharePluginStart['urlGenerators']; } export interface CoreServices { diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index 16ba9f9cd7a12..f7094a71a7792 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -6,15 +6,16 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; +import { StartDependencies } from '../types'; import { documentationService, uiMetricService, apiService, breadcrumbService } from './services'; import { renderApp } from '.'; export async function mountManagementSection( - { http, getStartServices, notifications }: CoreSetup, + { http, getStartServices, notifications }: CoreSetup, params: ManagementAppMountParams ) { const { element, setBreadcrumbs, history } = params; - const [coreStart] = await getStartServices(); + const [coreStart, depsStart] = await getStartServices(); const { docLinks, i18n: { Context: I18nContext }, @@ -31,6 +32,7 @@ export async function mountManagementSection( notifications, history, uiSettings: coreStart.uiSettings, + urlGenerators: depsStart.share.urlGenerators, }; return renderApp(element, I18nContext, services, { http }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts index 552e0ed0c41b2..2d6ab0477a603 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts @@ -120,6 +120,15 @@ export class ApiService { return result; } + + public async loadDocument(index: string, id: string) { + const result = await this.sendRequest({ + path: `${API_BASE_PATH}/documents/${encodeURIComponent(index)}/${encodeURIComponent(id)}`, + method: 'get', + }); + + return result; + } } export const apiService = new ApiService(); diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index 6c2f4a0898327..8b60967702742 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -9,11 +9,12 @@ import { CoreSetup, Plugin } from 'src/core/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; -import { Dependencies } from './types'; +import { SetupDependencies, StartDependencies } from './types'; import { registerUrlGenerator } from './url_generator'; -export class IngestPipelinesPlugin implements Plugin { - public setup(coreSetup: CoreSetup, plugins: Dependencies): void { +export class IngestPipelinesPlugin + implements Plugin { + public setup(coreSetup: CoreSetup, plugins: SetupDependencies): void { const { management, usageCollection, share } = plugins; const { http, getStartServices } = coreSetup; diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index 703b7a90f9356..13de8a74225ab 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -47,6 +47,7 @@ export { getFieldValidityAndErrorMessage, ValidationFunc, ValidationConfig, + useFormData, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { @@ -65,6 +66,7 @@ export { NumericField, SelectField, CheckBoxField, + TextField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { diff --git a/x-pack/plugins/ingest_pipelines/public/types.ts b/x-pack/plugins/ingest_pipelines/public/types.ts index e968c87226d07..1638e60e98505 100644 --- a/x-pack/plugins/ingest_pipelines/public/types.ts +++ b/x-pack/plugins/ingest_pipelines/public/types.ts @@ -6,10 +6,14 @@ import { ManagementSetup } from 'src/plugins/management/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { SharePluginSetup } from '../../../../src/plugins/share/public'; +import { SharePluginStart, SharePluginSetup } from 'src/plugins/share/public'; -export interface Dependencies { +export interface SetupDependencies { management: ManagementSetup; usageCollection: UsageCollectionSetup; share: SharePluginSetup; } + +export interface StartDependencies { + share: SharePluginStart; +} diff --git a/x-pack/plugins/ingest_pipelines/public/url_generator.ts b/x-pack/plugins/ingest_pipelines/public/url_generator.ts index 043d449a0440a..c53ff083ea098 100644 --- a/x-pack/plugins/ingest_pipelines/public/url_generator.ts +++ b/x-pack/plugins/ingest_pipelines/public/url_generator.ts @@ -13,7 +13,7 @@ import { getEditPath, getListPath, } from './application/services/navigation'; -import { Dependencies } from './types'; +import { SetupDependencies } from './types'; import { PLUGIN_ID } from '../common/constants'; export const INGEST_PIPELINES_APP_ULR_GENERATOR = 'INGEST_PIPELINES_APP_URL_GENERATOR'; @@ -83,8 +83,8 @@ export class IngestPipelinesUrlGenerator export const registerUrlGenerator = ( coreSetup: CoreSetup, - management: Dependencies['management'], - share: Dependencies['share'] + management: SetupDependencies['management'], + share: SetupDependencies['share'] ) => { const getAppBasePath = async (absolute = false) => { const [coreStart] = await coreSetup.getStartServices(); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts new file mode 100644 index 0000000000000..1f19112e069d5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const paramsSchema = schema.object({ + index: schema.string(), + id: schema.string(), +}); + +export const registerDocumentsRoute = ({ + router, + license, + lib: { isEsError }, +}: RouteDependencies): void => { + router.get( + { + path: `${API_BASE_PATH}/documents/{index}/{id}`, + validate: { + params: paramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { index, id } = req.params; + + try { + const document = await callAsCurrentUser('get', { index, id }); + + const { _id, _index, _source } = document; + + return res.ok({ + body: { + _id, + _index, + _source, + }, + }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts index 58a4bf5617659..7c0ab19917d1f 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts @@ -15,3 +15,5 @@ export { registerPrivilegesRoute } from './privileges'; export { registerDeleteRoute } from './delete'; export { registerSimulateRoute } from './simulate'; + +export { registerDocumentsRoute } from './documents'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/index.ts index f703a460143f4..5e80be4388b25 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/index.ts @@ -13,6 +13,7 @@ import { registerPrivilegesRoute, registerDeleteRoute, registerSimulateRoute, + registerDocumentsRoute, } from './api'; export class ApiRoutes { @@ -23,5 +24,6 @@ export class ApiRoutes { registerPrivilegesRoute(dependencies); registerDeleteRoute(dependencies); registerSimulateRoute(dependencies); + registerDocumentsRoute(dependencies); } } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index b3fab42a46114..b80306b0e6d38 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -14,7 +14,13 @@ const API_BASE_PATH = '/api/ingest_pipelines'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const { createPipeline, deletePipeline, cleanupPipelines } = registerEsHelpers(getService); + const { + createPipeline, + deletePipeline, + cleanupPipelines, + createIndex, + deleteIndex, + } = registerEsHelpers(getService); describe('Pipelines', function () { after(async () => { @@ -445,5 +451,59 @@ export default function ({ getService }: FtrProviderContext) { expect(body.docs?.length).to.eql(2); }); }); + + describe('Fetch documents', () => { + const INDEX = 'test_index'; + const DOCUMENT_ID = '1'; + const DOCUMENT = { + name: 'John Doe', + }; + + before(async () => { + // Create an index with a document that can be used to test GET request + try { + await createIndex({ id: DOCUMENT_ID, index: INDEX, body: DOCUMENT }); + } catch (err) { + // eslint-disable-next-line no-console + console.log('[Setup error] Error creating index'); + throw err; + } + }); + + after(async () => { + // Clean up index created + try { + await deleteIndex(INDEX); + } catch (err) { + // eslint-disable-next-line no-console + console.log('[Cleanup error] Error deleting index'); + throw err; + } + }); + + it('should return a document', async () => { + const uri = `${API_BASE_PATH}/documents/${INDEX}/${DOCUMENT_ID}`; + + const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(200); + + expect(body).to.eql({ + _index: INDEX, + _id: DOCUMENT_ID, + _source: DOCUMENT, + }); + }); + + it('should return an error if the document does not exist', async () => { + const uri = `${API_BASE_PATH}/documents/${INDEX}/2`; // Document 2 does not exist + + const { body } = await supertest.get(uri).set('kbn-xsrf', 'xxx').expect(404); + + expect(body).to.eql({ + error: 'Not Found', + message: 'Not Found', + statusCode: 404, + }); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts index 6de91e1154a85..aeed61cb0bf92 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts @@ -50,9 +50,19 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) console.log(`[Cleanup error] Error deleting ES resources: ${err.message}`); }); + const createIndex = (index: { index: string; id: string; body: object }) => { + return es.index(index); + }; + + const deleteIndex = (indexName: string) => { + return es.indices.delete({ index: indexName }); + }; + return { createPipeline, deletePipeline, cleanupPipelines, + createIndex, + deleteIndex, }; };