diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx index bbf1b95e251da..ad4ba100145d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx @@ -22,6 +22,8 @@ import { Loading } from '../../../../shared/loading'; jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); import { CurationLogic } from './curation_logic'; +import { AddResultFlyout } from './results'; + import { Curation } from './'; describe('Curation', () => { @@ -31,6 +33,7 @@ describe('Curation', () => { const values = { dataLoading: false, queries: ['query A', 'query B'], + isFlyoutOpen: false, }; const actions = { loadCuration: jest.fn(), @@ -60,6 +63,13 @@ describe('Curation', () => { expect(wrapper.find(Loading)).toHaveLength(1); }); + it('renders the add result flyout when open', () => { + setMockValues({ ...values, isFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(AddResultFlyout)).toHaveLength(1); + }); + it('initializes CurationLogic with a curationId prop from URL param', () => { (useParams as jest.Mock).mockReturnValueOnce({ curationId: 'hello-world' }); shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx index 85e91dabc6108..82679a2baddf0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx @@ -23,6 +23,7 @@ import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; import { CurationLogic } from './curation_logic'; import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; import { ActiveQuerySelect, ManageQueriesModal } from './queries'; +import { AddResultLogic, AddResultFlyout } from './results'; interface Props { curationsBreadcrumb: BreadcrumbTrail; @@ -32,6 +33,7 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { const { curationId } = useParams() as { curationId: string }; const { loadCuration, resetCuration } = useActions(CurationLogic({ curationId })); const { dataLoading, queries } = useValues(CurationLogic({ curationId })); + const { isFlyoutOpen } = useValues(AddResultLogic); useEffect(() => { loadCuration(); @@ -77,7 +79,7 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { - {/* TODO: AddResult flyout */} + {isFlyoutOpen && } > ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx index 78f5325ee567b..19fc7e1784d3d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import { setMockActions } from '../../../../../__mocks__'; + import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -14,9 +16,14 @@ import { EuiButton } from '@elastic/eui'; import { AddResultButton } from './'; describe('AddResultButton', () => { + const actions = { + openFlyout: jest.fn(), + }; + let wrapper: ShallowWrapper; beforeAll(() => { + setMockActions(actions); wrapper = shallow(); }); @@ -26,6 +33,6 @@ describe('AddResultButton', () => { it('opens the add result flyout on click', () => { wrapper.find(EuiButton).simulate('click'); - // TODO: assert on logic action + expect(actions.openFlyout).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx index 9bbc62ae51a0b..025dda65f4fb8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx @@ -7,12 +7,18 @@ import React from 'react'; +import { useActions } from 'kea'; + import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { AddResultLogic } from './'; + export const AddResultButton: React.FC = () => { + const { openFlyout } = useActions(AddResultLogic); + return ( - {} /* TODO */} iconType="plusInCircle" size="s" fill> + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.buttonLabel', { defaultMessage: 'Add result manually', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx new file mode 100644 index 0000000000000..e12267d0eb136 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout, EuiFieldSearch, EuiEmptyPrompt } from '@elastic/eui'; + +import { CurationResult, AddResultFlyout } from './'; + +describe('AddResultFlyout', () => { + const values = { + dataLoading: false, + searchQuery: '', + searchResults: [], + promotedIds: [], + hiddenIds: [], + }; + const actions = { + search: jest.fn(), + closeFlyout: jest.fn(), + addPromotedId: jest.fn(), + removePromotedId: jest.fn(), + addHiddenId: jest.fn(), + removeHiddenId: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders a closeable flyout', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + + wrapper.find(EuiFlyout).simulate('close'); + expect(actions.closeFlyout).toHaveBeenCalled(); + }); + + describe('search input', () => { + it('renders isLoading state correctly', () => { + setMockValues({ ...values, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toEqual(true); + }); + + it('renders value correctly', () => { + setMockValues({ ...values, searchQuery: 'hello world' }); + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('hello world'); + }); + + it('calls search on input change', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'lorem ipsum' } }); + + expect(actions.search).toHaveBeenCalledWith('lorem ipsum'); + }); + }); + + describe('search results', () => { + it('renders an empty state', () => { + setMockValues({ ...values, searchResults: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(CurationResult)).toHaveLength(0); + }); + + it('renders a result component for each item in searchResults', () => { + setMockValues({ + ...values, + searchResults: [ + { id: { raw: 'doc-1' } }, + { id: { raw: 'doc-2' } }, + { id: { raw: 'doc-3' } }, + ], + }); + const wrapper = shallow(); + + expect(wrapper.find(CurationResult)).toHaveLength(3); + }); + + describe('actions', () => { + it('renders a hide result button if the document ID is not already in the hiddenIds list', () => { + setMockValues({ + ...values, + searchResults: [{ id: { raw: 'visible-document' } }], + hiddenIds: ['hidden-document'], + }); + const wrapper = shallow(); + wrapper.find(CurationResult).prop('actions')[0].onClick(); + + expect(actions.addHiddenId).toHaveBeenCalledWith('visible-document'); + }); + + it('renders a show result button if the document ID is already in the hiddenIds list', () => { + setMockValues({ + ...values, + searchResults: [{ id: { raw: 'hidden-document' } }], + hiddenIds: ['hidden-document'], + }); + const wrapper = shallow(); + wrapper.find(CurationResult).prop('actions')[0].onClick(); + + expect(actions.removeHiddenId).toHaveBeenCalledWith('hidden-document'); + }); + + it('renders a promote result button if the document ID is not already in the promotedIds list', () => { + setMockValues({ + ...values, + searchResults: [{ id: { raw: 'some-document' } }], + promotedIds: ['promoted-document'], + }); + const wrapper = shallow(); + wrapper.find(CurationResult).prop('actions')[1].onClick(); + + expect(actions.addPromotedId).toHaveBeenCalledWith('some-document'); + }); + + it('renders a demote result button if the document ID is already in the promotedIds list', () => { + setMockValues({ + ...values, + searchResults: [{ id: { raw: 'promoted-document' } }], + promotedIds: ['promoted-document'], + }); + const wrapper = shallow(); + wrapper.find(CurationResult).prop('actions')[1].onClick(); + + expect(actions.removePromotedId).toHaveBeenCalledWith('promoted-document'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx new file mode 100644 index 0000000000000..6363919e32cc9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiPortal, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiText, + EuiSpacer, + EuiFieldSearch, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FlashMessages } from '../../../../../shared/flash_messages'; + +import { + RESULT_ACTIONS_DIRECTIONS, + PROMOTE_DOCUMENT_ACTION, + DEMOTE_DOCUMENT_ACTION, + HIDE_DOCUMENT_ACTION, + SHOW_DOCUMENT_ACTION, +} from '../../constants'; +import { CurationLogic } from '../curation_logic'; + +import { AddResultLogic, CurationResult } from './'; + +export const AddResultFlyout: React.FC = () => { + const { searchQuery, searchResults, dataLoading } = useValues(AddResultLogic); + const { search, closeFlyout } = useActions(AddResultLogic); + + const { promotedIds, hiddenIds } = useValues(CurationLogic); + const { addPromotedId, removePromotedId, addHiddenId, removeHiddenId } = useActions( + CurationLogic + ); + + return ( + + + + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.title', { + defaultMessage: 'Add result to curation', + })} + + + + {RESULT_ACTIONS_DIRECTIONS} + + + }> + search(e.target.value)} + isLoading={dataLoading} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.addResult.searchPlaceholder', + { defaultMessage: 'Search engine documents' } + )} + fullWidth + autoFocus + /> + + + {searchResults.length > 0 ? ( + searchResults.map((result) => { + const id = result.id.raw; + const isPromoted = promotedIds.includes(id); + const isHidden = hiddenIds.includes(id); + + return ( + removeHiddenId(id), + } + : { + ...HIDE_DOCUMENT_ACTION, + onClick: () => addHiddenId(id), + }, + isPromoted + ? { + ...DEMOTE_DOCUMENT_ACTION, + onClick: () => removePromotedId(id), + } + : { + ...PROMOTE_DOCUMENT_ACTION, + onClick: () => addPromotedId(id), + }, + ]} + /> + ); + }) + ) : ( + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts new file mode 100644 index 0000000000000..a722ab96fc574 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { AddResultLogic } from './'; + +describe('AddResultLogic', () => { + const { mount } = new LogicMounter(AddResultLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SEARCH_RESPONSE = { + results: [ + { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, + { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, + { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, + ], + }; + + const DEFAULT_VALUES = { + isFlyoutOpen: false, + dataLoading: false, + searchQuery: '', + searchResults: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(AddResultLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('openFlyout', () => { + it('sets isFlyoutOpen to true and resets the searchQuery term', () => { + mount({ isFlyoutOpen: false, searchQuery: 'a previous search' }); + + AddResultLogic.actions.openFlyout(); + + expect(AddResultLogic.values).toEqual({ + ...DEFAULT_VALUES, + isFlyoutOpen: true, + searchQuery: '', + }); + }); + }); + + describe('closeFlyout', () => { + it('sets isFlyoutOpen to false', () => { + mount({ isFlyoutOpen: true }); + + AddResultLogic.actions.closeFlyout(); + + expect(AddResultLogic.values).toEqual({ + ...DEFAULT_VALUES, + isFlyoutOpen: false, + }); + }); + }); + + describe('search', () => { + it('sets searchQuery & dataLoading to true', () => { + mount({ searchQuery: '', dataLoading: false }); + + AddResultLogic.actions.search('hello world'); + + expect(AddResultLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchQuery: 'hello world', + dataLoading: true, + }); + }); + }); + + describe('onSearch', () => { + it('sets searchResults & dataLoading to false', () => { + mount({ searchResults: [], dataLoading: true }); + + AddResultLogic.actions.onSearch(MOCK_SEARCH_RESPONSE); + + expect(AddResultLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: MOCK_SEARCH_RESPONSE.results, + dataLoading: false, + }); + }); + }); + }); + + describe('listeners', () => { + describe('search', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it('should make a GET API call with a search query', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); + mount(); + jest.spyOn(AddResultLogic.actions, 'onSearch'); + + AddResultLogic.actions.search('hello world'); + jest.runAllTimers(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/curation_search', + { query: { query: 'hello world' } } + ); + expect(AddResultLogic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + AddResultLogic.actions.search('test'); + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts new file mode 100644 index 0000000000000..808f4c86971ee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; + +import { EngineLogic } from '../../../engine'; +import { Result } from '../../../result/types'; + +interface AddResultValues { + isFlyoutOpen: boolean; + dataLoading: boolean; + searchQuery: string; + searchResults: Result[]; +} + +interface AddResultActions { + openFlyout(): void; + closeFlyout(): void; + search(query: string): { query: string }; + onSearch({ results }: { results: Result[] }): { results: Result[] }; +} + +export const AddResultLogic = kea>({ + path: ['enterprise_search', 'app_search', 'curation_add_result_logic'], + actions: () => ({ + openFlyout: true, + closeFlyout: true, + search: (query) => ({ query }), + onSearch: ({ results }) => ({ results }), + }), + reducers: () => ({ + isFlyoutOpen: [ + false, + { + openFlyout: () => true, + closeFlyout: () => false, + }, + ], + dataLoading: [ + false, + { + search: () => true, + onSearch: () => false, + }, + ], + searchQuery: [ + '', + { + search: (_, { query }) => query, + openFlyout: () => '', + }, + ], + searchResults: [ + [], + { + onSearch: (_, { results }) => results, + }, + ], + }), + listeners: ({ actions }) => ({ + search: async ({ query }, breakpoint) => { + await breakpoint(250); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/curation_search`, { + query: { query }, + }); + actions.onSearch(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/index.ts index 3c6339f0c1942..8de177ba587ce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +export { AddResultLogic } from './add_result_logic'; +export { AddResultFlyout } from './add_result_flyout'; export { AddResultButton } from './add_result_button'; export { CurationResult } from './curation_result'; export { convertToResultFormat } from './utils'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts index 08e123a98cd31..045d3d12e8bcf 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -229,4 +229,39 @@ describe('curations routes', () => { }); }); }); + + describe('GET /api/app_search/engines/{engineName}/curation_search', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/curation_search', + }); + + registerCurationsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v1/engines/:engineName/search.json', + }); + }); + + describe('validates', () => { + it('required query param', () => { + const request = { query: { query: 'some query' } }; + mockRouter.shouldValidate(request); + }); + + it('missing query', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts index 3cacab96d1968..4811ceeac408b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts @@ -115,4 +115,21 @@ export function registerCurationsRoutes({ path: '/as/engines/:engineName/curations/find_or_create', }) ); + + router.get( + { + path: '/api/app_search/engines/{engineName}/curation_search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v1/engines/:engineName/search.json', + }) + ); }
{RESULT_ACTIONS_DIRECTIONS}