From 0198607eb3a972801800d119cb36e8c0c6c72b9f Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 25 Feb 2021 14:07:05 -0800 Subject: [PATCH] [App Search] Create Curation view/functionality (#92560) * Add server route and logic listener * [Misc] Remove 'Set' from 'deleteCurationSet' - to match createCuration - IMO, this language isn't necessary if we're splitting up Curations and CurationLogic - the context is fairly evident within a smaller and more modular logic file * Add CurationQueries component + accompanying CurationQueriesLogic & CurationQuery row * Add CurationCreation view --- .../curation_queries/curation_queries.scss | 3 + .../curation_queries.test.tsx | 102 ++++++++++++++++++ .../curation_queries/curation_queries.tsx | 72 +++++++++++++ .../curation_queries_logic.test.ts | 98 +++++++++++++++++ .../curation_queries_logic.ts | 53 +++++++++ .../curation_queries/curation_query.test.tsx | 55 ++++++++++ .../curation_queries/curation_query.tsx | 51 +++++++++ .../components/curation_queries/index.ts | 8 ++ .../components/curation_queries/utils.test.ts | 15 +++ .../components/curation_queries/utils.ts | 10 ++ .../components/curations/components/index.ts | 8 ++ .../curations/curations_logic.test.ts | 43 +++++++- .../components/curations/curations_logic.ts | 27 ++++- .../components/curations/curations_router.tsx | 8 +- .../views/curation_creation.test.tsx | 40 +++++++ .../curations/views/curation_creation.tsx | 53 +++++++++ .../curations/views/curations.test.tsx | 8 +- .../components/curations/views/curations.tsx | 4 +- .../components/curations/views/index.ts | 1 + .../routes/app_search/curations.test.ts | 57 ++++++++++ .../server/routes/app_search/curations.ts | 17 +++ 21 files changed, 714 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss new file mode 100644 index 0000000000000..c242cf29fd37d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss @@ -0,0 +1,3 @@ +.curationQueryRow { + margin-bottom: $euiSizeXS; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx new file mode 100644 index 0000000000000..e55b944f7bebc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { CurationQuery } from './curation_query'; + +import { CurationQueries } from './'; + +describe('CurationQueries', () => { + const props = { + queries: ['a', 'b', 'c'], + onSubmit: jest.fn(), + }; + const values = { + queries: ['a', 'b', 'c'], + hasEmptyQueries: false, + hasOnlyOneQuery: false, + }; + const actions = { + addQuery: jest.fn(), + editQuery: jest.fn(), + deleteQuery: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders a CurationQuery row for each query', () => { + const wrapper = shallow(); + + expect(wrapper.find(CurationQuery)).toHaveLength(3); + expect(wrapper.find(CurationQuery).at(0).prop('queryValue')).toEqual('a'); + expect(wrapper.find(CurationQuery).at(1).prop('queryValue')).toEqual('b'); + expect(wrapper.find(CurationQuery).at(2).prop('queryValue')).toEqual('c'); + }); + + it('calls editQuery when the CurationQuery value changes', () => { + const wrapper = shallow(); + wrapper.find(CurationQuery).at(0).simulate('change', 'new query value'); + + expect(actions.editQuery).toHaveBeenCalledWith(0, 'new query value'); + }); + + it('calls deleteQuery when the CurationQuery calls onDelete', () => { + const wrapper = shallow(); + wrapper.find(CurationQuery).at(2).simulate('delete'); + + expect(actions.deleteQuery).toHaveBeenCalledWith(2); + }); + + it('calls addQuery when the Add Query button is clicked', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="addCurationQueryButton"]').simulate('click'); + + expect(actions.addQuery).toHaveBeenCalled(); + }); + + it('disables the add button if any query fields are empty', () => { + setMockValues({ + ...values, + queries: ['a', '', 'c'], + hasEmptyQueries: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="addCurationQueryButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + + it('calls the passed onSubmit callback when the submit button is clicked', () => { + setMockValues({ ...values, queries: ['some query'] }); + const wrapper = shallow(); + wrapper.find('[data-test-subj="submitCurationQueriesButton"]').simulate('click'); + + expect(props.onSubmit).toHaveBeenCalledWith(['some query']); + }); + + it('disables the submit button if no query fields have been filled', () => { + setMockValues({ + ...values, + queries: [''], + hasOnlyOneQuery: true, + hasEmptyQueries: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="submitCurationQueriesButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx new file mode 100644 index 0000000000000..ad7872b112408 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx @@ -0,0 +1,72 @@ +/* + * 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 { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Curation } from '../../types'; + +import { CurationQueriesLogic } from './curation_queries_logic'; +import { CurationQuery } from './curation_query'; +import { filterEmptyQueries } from './utils'; +import './curation_queries.scss'; + +interface Props { + queries: Curation['queries']; + onSubmit(queries: Curation['queries']): void; + submitButtonText?: string; +} + +export const CurationQueries: React.FC = ({ + queries: initialQueries, + onSubmit, + submitButtonText = i18n.translate('xpack.enterpriseSearch.actions.continue', { + defaultMessage: 'Continue', + }), +}) => { + const logic = CurationQueriesLogic({ queries: initialQueries }); + const { queries, hasEmptyQueries, hasOnlyOneQuery } = useValues(logic); + const { addQuery, editQuery, deleteQuery } = useActions(logic); + + return ( + <> + {queries.map((query: string, index) => ( + editQuery(index, newValue)} + onDelete={() => deleteQuery(index)} + disableDelete={hasOnlyOneQuery} + /> + ))} + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel', { + defaultMessage: 'Add query', + })} + + + onSubmit(filterEmptyQueries(queries))} + data-test-subj="submitCurationQueriesButton" + > + {submitButtonText} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts new file mode 100644 index 0000000000000..157e97433d2b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { resetContext } from 'kea'; + +import { CurationQueriesLogic } from './curation_queries_logic'; + +describe('CurationQueriesLogic', () => { + const MOCK_QUERIES = ['a', 'b', 'c']; + + const DEFAULT_PROPS = { queries: MOCK_QUERIES }; + const DEFAULT_VALUES = { + queries: MOCK_QUERIES, + hasEmptyQueries: false, + hasOnlyOneQuery: false, + }; + + const mount = (props = {}) => { + CurationQueriesLogic({ ...DEFAULT_PROPS, ...props }); + CurationQueriesLogic.mount(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + }); + + it('has expected default values passed from props', () => { + mount(); + expect(CurationQueriesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + afterEach(() => { + // Should not mutate the original array + expect(CurationQueriesLogic.values.queries).not.toBe(MOCK_QUERIES); // Would fail if we did not clone a new array + }); + + describe('addQuery', () => { + it('appends an empty string to the queries array', () => { + mount(); + CurationQueriesLogic.actions.addQuery(); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasEmptyQueries: true, + queries: ['a', 'b', 'c', ''], + }); + }); + }); + + describe('deleteQuery', () => { + it('deletes the query string at the specified array index', () => { + mount(); + CurationQueriesLogic.actions.deleteQuery(1); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + queries: ['a', 'c'], + }); + }); + }); + + describe('editQuery', () => { + it('edits the query string at the specified array index', () => { + mount(); + CurationQueriesLogic.actions.editQuery(2, 'z'); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + queries: ['a', 'b', 'z'], + }); + }); + }); + }); + + describe('selectors', () => { + describe('hasEmptyQueries', () => { + it('returns true if queries has any empty strings', () => { + mount({ queries: ['', '', ''] }); + + expect(CurationQueriesLogic.values.hasEmptyQueries).toEqual(true); + }); + }); + + describe('hasOnlyOneQuery', () => { + it('returns true if queries only has one item', () => { + mount({ queries: ['test'] }); + + expect(CurationQueriesLogic.values.hasOnlyOneQuery).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts new file mode 100644 index 0000000000000..98109657d61a3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts @@ -0,0 +1,53 @@ +/* + * 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'; + +interface CurationQueriesValues { + queries: string[]; + hasEmptyQueries: boolean; + hasOnlyOneQuery: boolean; +} + +interface CurationQueriesActions { + addQuery(): void; + deleteQuery(indexToDelete: number): { indexToDelete: number }; + editQuery(index: number, newQueryValue: string): { index: number; newQueryValue: string }; +} + +export const CurationQueriesLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'curation_queries_logic'], + actions: () => ({ + addQuery: true, + deleteQuery: (indexToDelete) => ({ indexToDelete }), + editQuery: (index, newQueryValue) => ({ index, newQueryValue }), + }), + reducers: ({ props }) => ({ + queries: [ + props.queries, + { + addQuery: (state) => [...state, ''], + deleteQuery: (state, { indexToDelete }) => { + const newState = [...state]; + newState.splice(indexToDelete, 1); + return newState; + }, + editQuery: (state, { index, newQueryValue }) => { + const newState = [...state]; + newState[index] = newQueryValue; + return newState; + }, + }, + ], + }), + selectors: { + hasEmptyQueries: [(selectors) => [selectors.queries], (queries) => queries.indexOf('') >= 0], + hasOnlyOneQuery: [(selectors) => [selectors.queries], (queries) => queries.length <= 1], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx new file mode 100644 index 0000000000000..64fbec59382a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiFieldText } from '@elastic/eui'; + +import { CurationQuery } from './curation_query'; + +describe('CurationQuery', () => { + const props = { + queryValue: 'some query', + onChange: jest.fn(), + onDelete: jest.fn(), + disableDelete: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldText)).toHaveLength(1); + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some query'); + }); + + it('calls onChange when the input value changes', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new query value' } }); + + expect(props.onChange).toHaveBeenCalledWith('new query value'); + }); + + it('calls onDelete when the delete button is clicked', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="deleteCurationQueryButton"]').simulate('click'); + + expect(props.onDelete).toHaveBeenCalled(); + }); + + it('disables the delete button if disableDelete is passed', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="deleteCurationQueryButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx new file mode 100644 index 0000000000000..78b32ef12e361 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + queryValue: string; + onChange(newValue: string): void; + onDelete(): void; + disableDelete: boolean; +} + +export const CurationQuery: React.FC = ({ + queryValue, + onChange, + onDelete, + disableDelete, +}) => ( + + + onChange(e.target.value)} + /> + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts new file mode 100644 index 0000000000000..4f9136d15d6c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CurationQueries } from './curation_queries'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts new file mode 100644 index 0000000000000..d84649f090691 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts @@ -0,0 +1,15 @@ +/* + * 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 { filterEmptyQueries } from './utils'; + +describe('filterEmptyQueries', () => { + it('filters out all empty strings from a queries array', () => { + const queries = ['', 'a', '', 'b', '', 'c', '']; + expect(filterEmptyQueries(queries)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts new file mode 100644 index 0000000000000..505e9641d778e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const filterEmptyQueries = (queries: string[]) => { + return queries.filter((query) => query.length); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts new file mode 100644 index 0000000000000..4f9136d15d6c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CurationQueries } from './curation_queries'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts index 1505fe5136bda..c1031fc20bc15 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import { + LogicMounter, + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../__mocks__'; import '../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; @@ -17,6 +22,7 @@ import { CurationsLogic } from './'; describe('CurationsLogic', () => { const { mount } = new LogicMounter(CurationsLogic); const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, setSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers; const MOCK_CURATIONS_RESPONSE = { @@ -128,7 +134,7 @@ describe('CurationsLogic', () => { }); }); - describe('deleteCurationSet', () => { + describe('deleteCuration', () => { const confirmSpy = jest.spyOn(window, 'confirm'); beforeEach(() => { @@ -140,7 +146,7 @@ describe('CurationsLogic', () => { mount(); jest.spyOn(CurationsLogic.actions, 'loadCurations'); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); @@ -155,7 +161,7 @@ describe('CurationsLogic', () => { http.delete.mockReturnValueOnce(Promise.reject('error')); mount(); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); @@ -166,12 +172,39 @@ describe('CurationsLogic', () => { confirmSpy.mockImplementationOnce(() => false); mount(); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); expect(http.delete).not.toHaveBeenCalled(); }); }); + + describe('createCuration', () => { + it('should make an API call and navigate to the new curation', async () => { + http.post.mockReturnValueOnce(Promise.resolve({ id: 'some-cur-id' })); + mount(); + + CurationsLogic.actions.createCuration(['some query']); + expect(clearFlashMessages).toHaveBeenCalled(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/engines/some-engine/curations', { + body: '{"queries":["some query"]}', + }); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/some-cur-id'); + }); + + it('handles errors', async () => { + http.post.mockReturnValueOnce(Promise.reject('error')); + mount(); + + CurationsLogic.actions.createCuration(['some query']); + expect(clearFlashMessages).toHaveBeenCalled(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts index 434aff9c3cc4b..f4916f54fbc22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts @@ -15,8 +15,10 @@ import { flashAPIErrors, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; import { updateMetaPageIndex } from '../../../shared/table_pagination'; -import { EngineLogic } from '../engine'; +import { ENGINE_CURATION_PATH } from '../../routes'; +import { EngineLogic, generateEnginePath } from '../engine'; import { DELETE_MESSAGE, SUCCESS_MESSAGE } from './constants'; import { Curation, CurationsAPIResponse } from './types'; @@ -31,7 +33,8 @@ interface CurationsActions { onCurationsLoad(response: CurationsAPIResponse): CurationsAPIResponse; onPaginate(newPageIndex: number): { newPageIndex: number }; loadCurations(): void; - deleteCurationSet(id: string): string; + deleteCuration(id: string): string; + createCuration(queries: Curation['queries']): Curation['queries']; } export const CurationsLogic = kea>({ @@ -40,7 +43,8 @@ export const CurationsLogic = kea ({ results, meta }), onPaginate: (newPageIndex) => ({ newPageIndex }), loadCurations: true, - deleteCurationSet: (id) => id, + deleteCuration: (id) => id, + createCuration: (queries) => queries, }), reducers: () => ({ dataLoading: [ @@ -82,7 +86,7 @@ export const CurationsLogic = kea { + deleteCuration: async (id) => { const { http } = HttpLogic.values; const { engineName } = EngineLogic.values; clearFlashMessages(); @@ -97,5 +101,20 @@ export const CurationsLogic = kea { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + const { navigateToUrl } = KibanaLogic.values; + clearFlashMessages(); + + try { + const response = await http.post(`/api/app_search/engines/${engineName}/curations`, { + body: JSON.stringify({ queries }), + }); + navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id })); + } catch (e) { + flashAPIErrors(e); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx index b4479fb145f81..634736bca4c65 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -19,8 +19,8 @@ import { ENGINE_CURATION_ADD_RESULT_PATH, } from '../../routes'; -import { CURATIONS_TITLE } from './constants'; -import { Curations } from './views'; +import { CURATIONS_TITLE, CREATE_NEW_CURATION_TITLE } from './constants'; +import { Curations, CurationCreation } from './views'; interface Props { engineBreadcrumb: BreadcrumbTrail; @@ -35,8 +35,8 @@ export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { - - TODO: Curation creation view + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx new file mode 100644 index 0000000000000..e6ddbb9c1b7a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 } from '../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { CurationQueries } from '../components'; + +import { CurationCreation } from './curation_creation'; + +describe('CurationCreation', () => { + const actions = { + createCuration: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(CurationQueries)).toHaveLength(1); + }); + + it('calls createCuration on CurationQueries submit', () => { + const wrapper = shallow(); + wrapper.find(CurationQueries).simulate('submit', ['some query']); + + expect(actions.createCuration).toHaveBeenCalledWith(['some query']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx new file mode 100644 index 0000000000000..b1bfc6c2ab7fa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx @@ -0,0 +1,53 @@ +/* + * 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 { useActions } from 'kea'; + +import { EuiPageHeader, EuiPageContent, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FlashMessages } from '../../../../shared/flash_messages'; + +import { CurationQueries } from '../components'; +import { CREATE_NEW_CURATION_TITLE } from '../constants'; +import { CurationsLogic } from '../index'; + +export const CurationCreation: React.FC = () => { + const { createCuration } = useActions(CurationsLogic); + + return ( + <> + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.create.curationQueriesTitle', + { defaultMessage: 'Curation queries' } + )} +

+
+ +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.create.curationQueriesDescription', + { + defaultMessage: + 'Add one or multiple queries to curate. You will be able add or remove more queries later.', + } + )} +

+
+ + createCuration(queries)} /> +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index fd5d5b7ea64a9..d06144023e170 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -51,7 +51,7 @@ describe('Curations', () => { const actions = { loadCurations: jest.fn(), - deleteCurationSet: jest.fn(), + deleteCuration: jest.fn(), onPaginate: jest.fn(), }; @@ -134,12 +134,12 @@ describe('Curations', () => { expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-id-2'); }); - it('delete action calls deleteCurationSet', () => { + it('delete action calls deleteCuration', () => { wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').first().simulate('click'); - expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-1'); + expect(actions.deleteCuration).toHaveBeenCalledWith('cur-id-1'); wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').last().simulate('click'); - expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-2'); + expect(actions.deleteCuration).toHaveBeenCalledWith('cur-id-2'); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index 6affef53d71ee..fd0a36dfebec7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -69,7 +69,7 @@ export const Curations: React.FC = () => { export const CurationsTable: React.FC = () => { const { dataLoading, curations, meta } = useValues(CurationsLogic); - const { onPaginate, deleteCurationSet } = useActions(CurationsLogic); + const { onPaginate, deleteCuration } = useActions(CurationsLogic); const columns: Array> = [ { @@ -141,7 +141,7 @@ export const CurationsTable: React.FC = () => { type: 'icon', icon: 'trash', color: 'danger', - onClick: (curation: Curation) => deleteCurationSet(curation.id), + onClick: (curation: Curation) => deleteCuration(curation.id), 'data-test-subj': 'CurationsTableDeleteButton', }, ], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts index d454d24f6c8b5..ca6924879324a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts @@ -6,3 +6,4 @@ */ export { Curations } from './curations'; +export { CurationCreation } from './curation_creation'; 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 4ac79068a88f5..28896809bc81a 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 @@ -50,6 +50,63 @@ describe('curations routes', () => { }); }); + describe('POST /api/app_search/engines/{engineName}/curations', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/curations', + }); + + registerCurationsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/curations/collection', + }); + }); + + describe('validates', () => { + it('with curation queries', () => { + const request = { + body: { + queries: ['a', 'b', 'c'], + }, + }; + mockRouter.shouldValidate(request); + }); + + it('empty queries array', () => { + const request = { + body: { + queries: [], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('empty query strings', () => { + const request = { + body: { + queries: ['', '', ''], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('missing queries', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + describe('DELETE /api/app_search/engines/{engineName}/curations/{curationId}', () => { let mockRouter: MockRouter; 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 48bb2fc5cb823..2d7f09e1aeb8d 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 @@ -31,6 +31,23 @@ export function registerCurationsRoutes({ }) ); + router.post( + { + path: '/api/app_search/engines/{engineName}/curations', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + queries: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/curations/collection', + }) + ); + router.delete( { path: '/api/app_search/engines/{engineName}/curations/{curationId}',