diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts new file mode 100644 index 0000000000000..782b8159c94a1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { resetContext } from 'kea'; + +import { mockHttpValues } from '../../../__mocks__'; +jest.mock('../../../shared/http', () => ({ + HttpLogic: { values: mockHttpValues }, +})); +const { http } = mockHttpValues; + +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'engine1' } }, +})); + +jest.mock('../../../shared/flash_messages', () => ({ + setQueuedSuccessMessage: jest.fn(), + flashAPIErrors: jest.fn(), +})); +import { setQueuedSuccessMessage, flashAPIErrors } from '../../../shared/flash_messages'; + +import { DocumentDetailLogic } from './document_detail_logic'; + +describe('DocumentDetailLogic', () => { + const DEFAULT_VALUES = { + dataLoading: true, + fields: [], + }; + + const mount = (defaults?: object) => { + if (!defaults) { + resetContext({}); + } else { + resetContext({ + defaults: { + enterprise_search: { + app_search: { + document_detail_logic: { + ...defaults, + }, + }, + }, + }, + }); + } + DocumentDetailLogic.mount(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('actions', () => { + describe('setFields', () => { + it('should set fields to the provided value and dataLoading to false', () => { + const fields = [{ name: 'foo', value: ['foo'], type: 'string' }]; + + mount({ + dataLoading: true, + fields: [], + }); + + DocumentDetailLogic.actions.setFields(fields); + + expect(DocumentDetailLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + fields, + }); + }); + }); + + describe('getDocumentDetails', () => { + it('will call an API endpoint and then store the result', async () => { + const fields = [{ name: 'name', value: 'python', type: 'string' }]; + jest.spyOn(DocumentDetailLogic.actions, 'setFields'); + const promise = Promise.resolve({ fields }); + http.get.mockReturnValue(promise); + + DocumentDetailLogic.actions.getDocumentDetails('1'); + + expect(http.get).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); + await promise; + expect(DocumentDetailLogic.actions.setFields).toHaveBeenCalledWith(fields); + }); + + it('handles errors', async () => { + mount(); + const promise = Promise.reject('An error occurred'); + http.get.mockReturnValue(promise); + + try { + DocumentDetailLogic.actions.getDocumentDetails('1'); + await promise; + } catch { + // Do nothing + } + expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred'); + }); + }); + + describe('deleteDocument', () => { + let confirmSpy: any; + let promise: Promise; + + beforeEach(() => { + confirmSpy = jest.spyOn(window, 'confirm'); + confirmSpy.mockImplementation(jest.fn(() => true)); + promise = Promise.resolve({}); + http.delete.mockReturnValue(promise); + }); + + afterEach(() => { + confirmSpy.mockRestore(); + }); + + it('will call an API endpoint and show a success message', async () => { + mount(); + DocumentDetailLogic.actions.deleteDocument('1'); + + expect(http.delete).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); + await promise; + expect(setQueuedSuccessMessage).toHaveBeenCalledWith( + 'Successfully marked document for deletion. It will be deleted momentarily.' + ); + }); + + it('will do nothing if not confirmed', async () => { + mount(); + window.confirm = () => false; + + DocumentDetailLogic.actions.deleteDocument('1'); + + expect(http.delete).not.toHaveBeenCalled(); + await promise; + }); + + it('handles errors', async () => { + mount(); + promise = Promise.reject('An error occured'); + http.delete.mockReturnValue(promise); + + try { + DocumentDetailLogic.actions.deleteDocument('1'); + await promise; + } catch { + // Do nothing + } + expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts new file mode 100644 index 0000000000000..87bf149fb1680 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts @@ -0,0 +1,92 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; +import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; +import { FieldDetails } from './types'; + +interface DocumentDetailLogicValues { + dataLoading: boolean; + fields: FieldDetails[]; +} + +interface DocumentDetailLogicActions { + setFields(fields: FieldDetails[]): { fields: FieldDetails[] }; + deleteDocument(documentId: string): { documentId: string }; + getDocumentDetails(documentId: string): { documentId: string }; +} + +type DocumentDetailLogicType = MakeLogicType; + +const CONFIRM_DELETE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentDetail.confirmDelete', + { + defaultMessage: 'Are you sure you want to delete this document?', + } +); +const DELETE_SUCCESS = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentDetail.deleteSuccess', + { + defaultMessage: 'Successfully marked document for deletion. It will be deleted momentarily.', + } +); + +export const DocumentDetailLogic = kea({ + path: ['enterprise_search', 'app_search', 'document_detail_logic'], + actions: () => ({ + setFields: (fields) => ({ fields }), + getDocumentDetails: (documentId) => ({ documentId }), + deleteDocument: (documentId) => ({ documentId }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + setFields: () => false, + }, + ], + fields: [ + [], + { + setFields: (_, { fields }) => fields, + }, + ], + }), + listeners: ({ actions }) => ({ + getDocumentDetails: async ({ documentId }) => { + const { engineName } = EngineLogic.values; + + try { + const { http } = HttpLogic.values; + // TODO: Handle 404s + const response = await http.get( + `/api/app_search/engines/${engineName}/documents/${documentId}` + ); + actions.setFields(response.fields); + } catch (e) { + flashAPIErrors(e); + } + }, + deleteDocument: async ({ documentId }) => { + const { engineName } = EngineLogic.values; + + if (window.confirm(CONFIRM_DELETE)) { + try { + const { http } = HttpLogic.values; + await http.delete(`/api/app_search/engines/${engineName}/documents/${documentId}`); + setQueuedSuccessMessage(DELETE_SUCCESS); + // TODO Handle routing after success + } catch (e) { + flashAPIErrors(e); + } + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.test.ts new file mode 100644 index 0000000000000..236172f0f7bdf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { resetContext } from 'kea'; + +import { DocumentsLogic } from './documents_logic'; + +describe('DocumentsLogic', () => { + const DEFAULT_VALUES = { + isDocumentCreationOpen: false, + }; + + const mount = (defaults?: object) => { + if (!defaults) { + resetContext({}); + } else { + resetContext({ + defaults: { + enterprise_search: { + app_search: { + documents_logic: { + ...defaults, + }, + }, + }, + }, + }); + } + DocumentsLogic.mount(); + }; + + describe('actions', () => { + describe('openDocumentCreation', () => { + it('should toggle isDocumentCreationOpen to true', () => { + mount({ + isDocumentCreationOpen: false, + }); + + DocumentsLogic.actions.openDocumentCreation(); + + expect(DocumentsLogic.values).toEqual({ + ...DEFAULT_VALUES, + isDocumentCreationOpen: true, + }); + }); + }); + + describe('closeDocumentCreation', () => { + it('should toggle isDocumentCreationOpen to false', () => { + mount({ + isDocumentCreationOpen: true, + }); + + DocumentsLogic.actions.closeDocumentCreation(); + + expect(DocumentsLogic.values).toEqual({ + ...DEFAULT_VALUES, + isDocumentCreationOpen: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.ts new file mode 100644 index 0000000000000..dcf1a883bd3b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents_logic.ts @@ -0,0 +1,35 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +interface DocumentsLogicValues { + isDocumentCreationOpen: boolean; +} + +interface DocumentsLogicActions { + closeDocumentCreation(): void; + openDocumentCreation(): void; +} + +type DocumentsLogicType = MakeLogicType; + +export const DocumentsLogic = kea({ + path: ['enterprise_search', 'app_search', 'documents_logic'], + actions: () => ({ + openDocumentCreation: true, + closeDocumentCreation: true, + }), + reducers: () => ({ + isDocumentCreationOpen: [ + false, + { + openDocumentCreation: () => true, + closeDocumentCreation: () => false, + }, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/index.ts new file mode 100644 index 0000000000000..d374098d70788 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DocumentDetailLogic } from './document_detail_logic'; +export { DocumentsLogic } from './documents_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts new file mode 100644 index 0000000000000..6a7c1cd1d5d2f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts @@ -0,0 +1,11 @@ +/* + * 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 interface FieldDetails { + name: string; + value: string | string[]; + type: string; +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts new file mode 100644 index 0000000000000..d5fed4c6f97cb --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerDocumentRoutes } from './documents'; + +describe('document routes', () => { + describe('GET /api/app_search/engines/{engineName}/documents/{documentId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/documents/{documentId}', + }); + + registerDocumentRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ params: { engineName: 'some-engine', documentId: '1' } }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/documents/1', + }); + }); + }); + + describe('DELETE /api/app_search/engines/{engineName}/documents/{documentId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/app_search/engines/{engineName}/documents/{documentId}', + }); + + registerDocumentRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ params: { engineName: 'some-engine', documentId: '1' } }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/documents/1', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts new file mode 100644 index 0000000000000..a2f4b323a91aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts @@ -0,0 +1,47 @@ +/* + * 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 { RouteDependencies } from '../../plugin'; + +export function registerDocumentRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/documents/{documentId}', + validate: { + params: schema.object({ + engineName: schema.string(), + documentId: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${request.params.engineName}/documents/${request.params.documentId}`, + })(context, request, response); + } + ); + router.delete( + { + path: '/api/app_search/engines/{engineName}/documents/{documentId}', + validate: { + params: schema.object({ + engineName: schema.string(), + documentId: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${request.params.engineName}/documents/${request.params.documentId}`, + })(context, request, response); + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index faf74203cf17d..f64e45c656fa1 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,9 +9,11 @@ import { RouteDependencies } from '../../plugin'; import { registerEnginesRoutes } from './engines'; import { registerCredentialsRoutes } from './credentials'; import { registerSettingsRoutes } from './settings'; +import { registerDocumentRoutes } from './documents'; export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerEnginesRoutes(dependencies); registerCredentialsRoutes(dependencies); registerSettingsRoutes(dependencies); + registerDocumentRoutes(dependencies); };