diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx index 5afbce3661da3..bf64101527fd2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx @@ -16,7 +16,7 @@ import { nextTick } from '@kbn/test/jest'; import { DEFAULT_META } from '../../../../shared/constants'; -import { SuggestionsLogic } from './suggestions_logic'; +import { SuggestionsAPIResponse, SuggestionsLogic } from './suggestions_logic'; const DEFAULT_VALUES = { dataLoading: true, @@ -30,7 +30,7 @@ const DEFAULT_VALUES = { }, }; -const MOCK_RESPONSE = { +const MOCK_RESPONSE: SuggestionsAPIResponse = { meta: { page: { current: 1, @@ -44,6 +44,7 @@ const MOCK_RESPONSE = { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2'], + status: 'applied', }, ], }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx index 9352bdab51edd..074d2114ee8cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx @@ -15,7 +15,7 @@ import { updateMetaPageIndex } from '../../../../shared/table_pagination'; import { EngineLogic } from '../../engine'; import { CurationSuggestion } from '../types'; -interface SuggestionsAPIResponse { +export interface SuggestionsAPIResponse { results: CurationSuggestion[]; meta: Meta; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts index f8c3e3efdbc1d..01ca80776ae85 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts @@ -50,6 +50,13 @@ export const RESTORE_CONFIRMATION = i18n.translate( } ); +export const CONVERT_TO_MANUAL_CONFIRMATION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.convertToManualCurationConfirmation', + { + defaultMessage: 'Are you sure you want to convert this to a manual curation?', + } +); + export const RESULT_ACTIONS_DIRECTIONS = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.resultActionsDescription', { defaultMessage: 'Promote results by clicking the star, hide them by clicking the eye.' } @@ -82,3 +89,13 @@ export const SHOW_DOCUMENT_ACTION = { iconType: 'eye', iconColor: 'primary' as EuiButtonIconColor, }; + +export const AUTOMATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.automatedLabel', + { defaultMessage: 'Automated' } +); + +export const COVERT_TO_MANUAL_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.convertToManualCurationButtonLabel', + { defaultMessage: 'Convert to manual curation' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx new file mode 100644 index 0000000000000..3139d62863729 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiBadge } from '@elastic/eui'; + +import { getPageHeaderActions, getPageTitle } from '../../../../test_helpers'; + +jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); + +import { AppSearchPageTemplate } from '../../layout'; + +import { AutomatedCuration } from './automated_curation'; +import { CurationLogic } from './curation_logic'; + +import { PromotedDocuments, OrganicDocuments } from './documents'; + +describe('AutomatedCuration', () => { + const values = { + dataLoading: false, + queries: ['query A', 'query B'], + isFlyoutOpen: false, + curation: { + suggestion: { + status: 'applied', + }, + }, + activeQuery: 'query A', + isAutomated: true, + }; + + const actions = { + convertToManual: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + mockUseParams.mockReturnValue({ curationId: 'test' }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.is(AppSearchPageTemplate)); + expect(wrapper.find(PromotedDocuments)).toHaveLength(1); + expect(wrapper.find(OrganicDocuments)).toHaveLength(1); + }); + + it('initializes CurationLogic with a curationId prop from URL param', () => { + mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); + shallow(); + + expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); + }); + + it('displays the query in the title with a badge', () => { + const wrapper = shallow(); + const pageTitle = shallow(
{getPageTitle(wrapper)}
); + + expect(pageTitle.text()).toContain('query A'); + expect(pageTitle.find(EuiBadge)).toHaveLength(1); + }); + + describe('convert to manual button', () => { + let convertToManualButton: ShallowWrapper; + let confirmSpy: jest.SpyInstance; + + beforeAll(() => { + const wrapper = shallow(); + convertToManualButton = getPageHeaderActions(wrapper).childAt(0); + + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + confirmSpy.mockRestore(); + }); + + it('converts the curation upon user confirmation', () => { + confirmSpy.mockReturnValueOnce(true); + convertToManualButton.simulate('click'); + expect(actions.convertToManual).toHaveBeenCalled(); + }); + + it('does not convert the curation if the user cancels', () => { + confirmSpy.mockReturnValueOnce(false); + convertToManualButton.simulate('click'); + expect(actions.convertToManual).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx new file mode 100644 index 0000000000000..1415537e42d6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx @@ -0,0 +1,65 @@ +/* + * 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 { useParams } from 'react-router-dom'; + +import { useValues, useActions } from 'kea'; + +import { EuiSpacer, EuiButton, EuiBadge } from '@elastic/eui'; + +import { AppSearchPageTemplate } from '../../layout'; +import { AutomatedIcon } from '../components/automated_icon'; +import { + AUTOMATED_LABEL, + COVERT_TO_MANUAL_BUTTON_LABEL, + CONVERT_TO_MANUAL_CONFIRMATION, +} from '../constants'; +import { getCurationsBreadcrumbs } from '../utils'; + +import { CurationLogic } from './curation_logic'; +import { PromotedDocuments, OrganicDocuments } from './documents'; + +export const AutomatedCuration: React.FC = () => { + const { curationId } = useParams<{ curationId: string }>(); + const logic = CurationLogic({ curationId }); + const { convertToManual } = useActions(logic); + const { activeQuery, dataLoading, queries } = useValues(logic); + + return ( + + {activeQuery}{' '} + + {AUTOMATED_LABEL} + + + ), + rightSideItems: [ + { + if (window.confirm(CONVERT_TO_MANUAL_CONFIRMATION)) convertToManual(); + }} + > + {COVERT_TO_MANUAL_BUTTON_LABEL} + , + ], + }} + isLoading={dataLoading} + > + + + + + ); +}; 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 2efe1f2ffe86f..62c3a6c7d4578 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 @@ -12,26 +12,25 @@ import '../../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { rerender, getPageTitle, getPageHeaderActions } from '../../../../test_helpers'; +import { rerender } from '../../../../test_helpers'; jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); -import { CurationLogic } from './curation_logic'; -import { AddResultFlyout } from './results'; +import { AutomatedCuration } from './automated_curation'; + +import { ManualCuration } from './manual_curation'; import { Curation } from './'; describe('Curation', () => { const values = { - dataLoading: false, - queries: ['query A', 'query B'], - isFlyoutOpen: false, + isAutomated: true, }; + const actions = { loadCuration: jest.fn(), - resetCuration: jest.fn(), }; beforeEach(() => { @@ -40,32 +39,6 @@ describe('Curation', () => { setMockActions(actions); }); - it('renders', () => { - const wrapper = shallow(); - - expect(getPageTitle(wrapper)).toEqual('Manage curation'); - expect(wrapper.prop('pageChrome')).toEqual([ - 'Engines', - 'some-engine', - 'Curations', - 'query A, query B', - ]); - }); - - 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', () => { - mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); - shallow(); - - expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); - }); - it('calls loadCuration on page load & whenever the curationId URL param changes', () => { mockUseParams.mockReturnValueOnce({ curationId: 'cur-123456789' }); const wrapper = shallow(); @@ -76,31 +49,17 @@ describe('Curation', () => { expect(actions.loadCuration).toHaveBeenCalledTimes(2); }); - describe('restore defaults button', () => { - let restoreDefaultsButton: ShallowWrapper; - let confirmSpy: jest.SpyInstance; - - beforeAll(() => { - const wrapper = shallow(); - restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0); - - confirmSpy = jest.spyOn(window, 'confirm'); - }); + it('renders a view for automated curations', () => { + setMockValues({ isAutomated: true }); + const wrapper = shallow(); - afterAll(() => { - confirmSpy.mockRestore(); - }); + expect(wrapper.is(AutomatedCuration)).toBe(true); + }); - it('resets the curation upon user confirmation', () => { - confirmSpy.mockReturnValueOnce(true); - restoreDefaultsButton.simulate('click'); - expect(actions.resetCuration).toHaveBeenCalled(); - }); + it('renders a view for manual curations', () => { + setMockValues({ isAutomated: false }); + const wrapper = shallow(); - it('does not reset the curation if the user cancels', () => { - confirmSpy.mockReturnValueOnce(false); - restoreDefaultsButton.simulate('click'); - expect(actions.resetCuration).not.toHaveBeenCalled(); - }); + expect(wrapper.is(ManualCuration)).toBe(true); }); }); 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 2a01c0db049ab..19b6542e96c4b 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 @@ -10,64 +10,18 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; - -import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants'; -import { AppSearchPageTemplate } from '../../layout'; -import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; -import { getCurationsBreadcrumbs } from '../utils'; - +import { AutomatedCuration } from './automated_curation'; import { CurationLogic } from './curation_logic'; -import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; -import { ActiveQuerySelect, ManageQueriesModal } from './queries'; -import { AddResultLogic, AddResultFlyout } from './results'; +import { ManualCuration } from './manual_curation'; export const Curation: React.FC = () => { const { curationId } = useParams() as { curationId: string }; - const { loadCuration, resetCuration } = useActions(CurationLogic({ curationId })); - const { dataLoading, queries } = useValues(CurationLogic({ curationId })); - const { isFlyoutOpen } = useValues(AddResultLogic); + const { loadCuration } = useActions(CurationLogic({ curationId })); + const { isAutomated } = useValues(CurationLogic({ curationId })); useEffect(() => { loadCuration(); }, [curationId]); - return ( - { - if (window.confirm(RESTORE_CONFIRMATION)) resetCuration(); - }} - > - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - ], - }} - isLoading={dataLoading} - > - - - - - - - - - - - - - - - - - - {isFlyoutOpen && } - - ); + return isAutomated ? : ; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts index 8fa57e52a26a1..941fd0bf28f96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts @@ -55,6 +55,7 @@ describe('CurationLogic', () => { promotedDocumentsLoading: false, hiddenIds: [], hiddenDocumentsLoading: false, + isAutomated: false, }; beforeEach(() => { @@ -265,7 +266,60 @@ describe('CurationLogic', () => { }); }); + describe('selectors', () => { + describe('isAutomated', () => { + it('is true when suggestion status is automated', () => { + mount({ curation: { suggestion: { status: 'automated' } } }); + + expect(CurationLogic.values.isAutomated).toBe(true); + }); + + it('is false when suggestion status is not automated', () => { + for (status of ['pending', 'applied', 'rejected', 'disabled']) { + mount({ curation: { suggestion: { status } } }); + + expect(CurationLogic.values.isAutomated).toBe(false); + } + }); + }); + }); + describe('listeners', () => { + describe('convertToManual', () => { + it('should make an API call and re-load the curation on success', async () => { + http.put.mockReturnValueOnce(Promise.resolve()); + mount({ activeQuery: 'some query' }); + jest.spyOn(CurationLogic.actions, 'loadCuration'); + + CurationLogic.actions.convertToManual(); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions', + { + body: JSON.stringify([ + { + query: 'some query', + type: 'curation', + status: 'applied', + }, + ]), + } + ); + expect(CurationLogic.actions.loadCuration).toHaveBeenCalled(); + }); + + it('flashes any error messages', async () => { + http.put.mockReturnValueOnce(Promise.reject('error')); + mount({ activeQuery: 'some query' }); + + CurationLogic.actions.convertToManual(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + describe('loadCuration', () => { it('should set dataLoading state', () => { mount({ dataLoading: false }, { curationId: 'cur-123456789' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts index c49fc76d06874..a9fa5ab8c1048 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts @@ -27,9 +27,11 @@ interface CurationValues { promotedDocumentsLoading: boolean; hiddenIds: string[]; hiddenDocumentsLoading: boolean; + isAutomated: boolean; } interface CurationActions { + convertToManual(): void; loadCuration(): void; onCurationLoad(curation: Curation): { curation: Curation }; updateCuration(): void; @@ -53,6 +55,7 @@ interface CurationProps { export const CurationLogic = kea>({ path: ['enterprise_search', 'app_search', 'curation_logic'], actions: () => ({ + convertToManual: true, loadCuration: true, onCurationLoad: (curation) => ({ curation }), updateCuration: true, @@ -162,7 +165,34 @@ export const CurationLogic = kea ({ + isAutomated: [ + () => [selectors.curation], + (curation: CurationValues['curation']) => { + return curation.suggestion?.status === 'automated'; + }, + ], + }), listeners: ({ actions, values, props }) => ({ + convertToManual: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + await http.put(`/internal/app_search/engines/${engineName}/search_relevance_suggestions`, { + body: JSON.stringify([ + { + query: values.activeQuery, + type: 'curation', + status: 'applied', + }, + ]), + }); + actions.loadCuration(); + } catch (e) { + flashAPIErrors(e); + } + }, loadCuration: async () => { const { http } = HttpLogic.values; const { engineName } = EngineLogic.values; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx index 0624d0063e57d..b7955cf514079 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui'; +import { mountWithIntl } from '../../../../../test_helpers'; + import { DataPanel } from '../../../data_panel'; import { CurationResult } from '../results'; @@ -30,6 +32,7 @@ describe('OrganicDocuments', () => { }, activeQuery: 'world', organicDocumentsLoading: false, + isAutomated: false, }; const actions = { addPromotedId: jest.fn(), @@ -56,6 +59,13 @@ describe('OrganicDocuments', () => { expect(titleText).toEqual('Top organic documents for "world"'); }); + it('shows a title when the curation is manual', () => { + setMockValues({ ...values, isAutomated: false }); + const wrapper = shallow(); + + expect(wrapper.find(DataPanel).prop('subtitle')).toContain('Promote results'); + }); + it('renders a loading state', () => { setMockValues({ ...values, organicDocumentsLoading: true }); const wrapper = shallow(); @@ -63,11 +73,21 @@ describe('OrganicDocuments', () => { expect(wrapper.find(EuiLoadingContent)).toHaveLength(1); }); - it('renders an empty state', () => { - setMockValues({ ...values, curation: { organic: [] } }); - const wrapper = shallow(); + describe('empty state', () => { + it('renders', () => { + setMockValues({ ...values, curation: { organic: [] } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + it('tells the user to modify the query if the curation is manual', () => { + setMockValues({ ...values, curation: { organic: [] }, isAutomated: false }); + const wrapper = shallow(); + const emptyPromptBody = mountWithIntl(<>{wrapper.find(EuiEmptyPrompt).prop('body')}); + + expect(emptyPromptBody.text()).toContain('Add or change'); + }); }); describe('actions', () => { @@ -86,5 +106,13 @@ describe('OrganicDocuments', () => { expect(actions.addHiddenId).toHaveBeenCalledWith('mock-document-3'); }); + + it('hides actions when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(); + const result = wrapper.find(CurationResult).first(); + + expect(result.prop('actions')).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx index a3a761feefcd2..7314376a4a7ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx @@ -11,6 +11,7 @@ import { useValues, useActions } from 'kea'; import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DataPanel } from '../../../data_panel'; import { Result } from '../../../result/types'; @@ -25,7 +26,7 @@ import { CurationResult } from '../results'; export const OrganicDocuments: React.FC = () => { const { addPromotedId, addHiddenId } = useActions(CurationLogic); - const { curation, activeQuery, organicDocumentsLoading } = useValues(CurationLogic); + const { curation, activeQuery, isAutomated, organicDocumentsLoading } = useValues(CurationLogic); const documents = curation.organic; const hasDocuments = documents.length > 0 && !organicDocumentsLoading; @@ -46,36 +47,50 @@ export const OrganicDocuments: React.FC = () => { )} } - subtitle={RESULT_ACTIONS_DIRECTIONS} + subtitle={!isAutomated && RESULT_ACTIONS_DIRECTIONS} > {hasDocuments ? ( documents.map((document: Result) => ( addHiddenId(document.id.raw), - }, - { - ...PROMOTE_DOCUMENT_ACTION, - onClick: () => addPromotedId(document.id.raw), - }, - ]} + actions={ + isAutomated + ? [] + : [ + { + ...HIDE_DOCUMENT_ACTION, + onClick: () => addHiddenId(document.id.raw), + }, + { + ...PROMOTE_DOCUMENT_ACTION, + onClick: () => addPromotedId(document.id.raw), + }, + ] + } /> )) ) : organicDocumentsLoading ? ( ) : ( + {' '} + + + ), + }} + /> + } /> )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx index e0c6de973666c..a66b33a47f35c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { setMockValues, setMockActions } from '../../../../../__mocks__/kea_logic'; import React from 'react'; @@ -13,6 +12,7 @@ import { shallow } from 'enzyme'; import { EuiDragDropContext, EuiDraggable, EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui'; +import { mountWithIntl } from '../../../../../test_helpers'; import { DataPanel } from '../../../data_panel'; import { CurationResult } from '../results'; @@ -57,11 +57,50 @@ describe('PromotedDocuments', () => { }); }); - it('renders an empty state & hides the panel actions when empty', () => { + it('informs the user documents can be re-ordered if the curation is manual', () => { + setMockValues({ ...values, isAutomated: false }); + const wrapper = shallow(); + const subtitle = mountWithIntl(wrapper.prop('subtitle')); + + expect(subtitle.text()).toContain('Documents can be re-ordered'); + }); + + it('informs the user the curation is managed if the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(); + const subtitle = mountWithIntl(wrapper.prop('subtitle')); + + expect(subtitle.text()).toContain('managed by App Search'); + }); + + describe('empty state', () => { + it('renders', () => { + setMockValues({ ...values, curation: { promoted: [] } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('hide information about starring documents if the curation is automated', () => { + setMockValues({ ...values, curation: { promoted: [] }, isAutomated: true }); + const wrapper = shallow(); + const emptyPromptBody = mountWithIntl(<>{wrapper.find(EuiEmptyPrompt).prop('body')}); + + expect(emptyPromptBody.text()).not.toContain('Star documents'); + }); + }); + + it('hides the panel actions when empty', () => { setMockValues({ ...values, curation: { promoted: [] } }); const wrapper = shallow(); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(DataPanel).prop('action')).toBe(false); + }); + + it('hides the panel actions when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(); + expect(wrapper.find(DataPanel).prop('action')).toBe(false); }); @@ -81,6 +120,14 @@ describe('PromotedDocuments', () => { expect(actions.removePromotedId).toHaveBeenCalledWith('mock-document-4'); }); + it('hides demote button for results when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(); + const result = getDraggableChildren(wrapper.find(EuiDraggable).last()); + + expect(result.prop('actions')).toEqual([]); + }); + it('renders a demote all button that demotes all hidden results', () => { const wrapper = shallow(); const panelActions = shallow(wrapper.find(DataPanel).prop('action') as React.ReactElement); @@ -89,7 +136,7 @@ describe('PromotedDocuments', () => { expect(actions.clearPromotedIds).toHaveBeenCalled(); }); - describe('draggging', () => { + describe('dragging', () => { it('calls setPromotedIds with the reordered list when users are done dragging', () => { const wrapper = shallow(); wrapper.find(EuiDragDropContext).simulate('dragEnd', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx index 6b0a02aa2af58..e9d9136a45ac6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx @@ -21,6 +21,7 @@ import { euiDragDropReorder, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DataPanel } from '../../../data_panel'; @@ -29,7 +30,7 @@ import { CurationLogic } from '../curation_logic'; import { AddResultButton, CurationResult, convertToResultFormat } from '../results'; export const PromotedDocuments: React.FC = () => { - const { curation, promotedIds, promotedDocumentsLoading } = useValues(CurationLogic); + const { curation, isAutomated, promotedIds, promotedDocumentsLoading } = useValues(CurationLogic); const documents = curation.promoted; const hasDocuments = documents.length > 0; @@ -53,21 +54,33 @@ export const PromotedDocuments: React.FC = () => { )} } - subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description', - { - defaultMessage: - 'Promoted results appear before organic results. Documents can be re-ordered.', - } - )} + subtitle={ + isAutomated ? ( + + ) : ( + + ) + } action={ + !isAutomated && hasDocuments && ( - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel', { defaultMessage: 'Demote all' } @@ -89,17 +102,22 @@ export const PromotedDocuments: React.FC = () => { draggableId={document.id} customDragHandle spacing="none" + isDragDisabled={isAutomated} > {(provided) => ( removePromotedId(document.id), - }, - ]} + actions={ + isAutomated + ? [] + : [ + { + ...DEMOTE_DOCUMENT_ACTION, + onClick: () => removePromotedId(document.id), + }, + ] + } dragHandleProps={provided.dragHandleProps} /> )} @@ -109,13 +127,22 @@ export const PromotedDocuments: React.FC = () => { ) : ( } /> )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx new file mode 100644 index 0000000000000..ad9f3bcd64e3e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { getPageTitle, getPageHeaderActions } from '../../../../test_helpers'; + +jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); +import { CurationLogic } from './curation_logic'; + +import { ManualCuration } from './manual_curation'; +import { AddResultFlyout } from './results'; + +describe('ManualCuration', () => { + const values = { + dataLoading: false, + queries: ['query A', 'query B'], + isFlyoutOpen: false, + }; + const actions = { + resetCuration: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(getPageTitle(wrapper)).toEqual('Manage curation'); + expect(wrapper.prop('pageChrome')).toEqual([ + 'Engines', + 'some-engine', + 'Curations', + 'query A, query B', + ]); + }); + + 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', () => { + mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); + shallow(); + + expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); + }); + + describe('restore defaults button', () => { + let restoreDefaultsButton: ShallowWrapper; + let confirmSpy: jest.SpyInstance; + + beforeAll(() => { + const wrapper = shallow(); + restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0); + + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + confirmSpy.mockRestore(); + }); + + it('resets the curation upon user confirmation', () => { + confirmSpy.mockReturnValueOnce(true); + restoreDefaultsButton.simulate('click'); + expect(actions.resetCuration).toHaveBeenCalled(); + }); + + it('does not reset the curation if the user cancels', () => { + confirmSpy.mockReturnValueOnce(false); + restoreDefaultsButton.simulate('click'); + expect(actions.resetCuration).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx new file mode 100644 index 0000000000000..d50575535bf21 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx @@ -0,0 +1,68 @@ +/* + * 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 { useParams } from 'react-router-dom'; + +import { useValues, useActions } from 'kea'; + +import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; + +import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants'; +import { AppSearchPageTemplate } from '../../layout'; +import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; +import { getCurationsBreadcrumbs } from '../utils'; + +import { CurationLogic } from './curation_logic'; +import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; +import { ActiveQuerySelect, ManageQueriesModal } from './queries'; +import { AddResultLogic, AddResultFlyout } from './results'; + +export const ManualCuration: React.FC = () => { + const { curationId } = useParams() as { curationId: string }; + const { resetCuration } = useActions(CurationLogic({ curationId })); + const { dataLoading, queries } = useValues(CurationLogic({ curationId })); + const { isFlyoutOpen } = useValues(AddResultLogic); + + return ( + { + if (window.confirm(RESTORE_CONFIRMATION)) resetCuration(); + }} + > + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + ], + }} + isLoading={dataLoading} + > + + + + + + + + + + + + + + + + + {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 53cefdd00c670..5b5c814a24c5b 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,34 +5,43 @@ * 2.0. */ -import { setMockActions } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { EuiButton } from '@elastic/eui'; import { AddResultButton } from './'; describe('AddResultButton', () => { + const values = { + isAutomated: false, + }; + const actions = { openFlyout: jest.fn(), }; - let wrapper: ShallowWrapper; - - beforeAll(() => { - setMockActions(actions); - wrapper = shallow(); - }); - it('renders', () => { - expect(wrapper.find(EuiButton)).toHaveLength(1); + const wrapper = shallow(); + + expect(wrapper.is(EuiButton)).toBe(true); }); it('opens the add result flyout on click', () => { + setMockActions(actions); + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); expect(actions.openFlyout).toHaveBeenCalled(); }); + + it('is disbled when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButton).prop('disabled')).toBe(true); + }); }); 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 025dda65f4fb8..f2285064da307 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,18 +7,21 @@ import React from 'react'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { CurationLogic } from '..'; + import { AddResultLogic } from './'; export const AddResultButton: React.FC = () => { const { openFlyout } = useActions(AddResultLogic); + const { isAutomated } = useValues(CurationLogic); return ( - + {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/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts index 866bf6490ebe8..09c8a487b1b9b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts @@ -12,7 +12,9 @@ export interface CurationSuggestion { query: string; updated_at: string; promoted: string[]; + status: 'pending' | 'applied' | 'automated' | 'rejected' | 'disabled'; } + export interface Curation { id: string; last_updated: string; @@ -20,6 +22,7 @@ export interface Curation { promoted: CurationResult[]; hidden: CurationResult[]; organic: Result[]; + suggestion?: CurationSuggestion; } export interface CurationsAPIResponse { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts index 6e616dcd9452c..9edeab4b658ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts @@ -15,6 +15,8 @@ import '../../../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; +import { CurationSuggestion } from '../../types'; + import { CurationSuggestionLogic } from './curation_suggestion_logic'; const DEFAULT_VALUES = { @@ -23,10 +25,11 @@ const DEFAULT_VALUES = { suggestedPromotedDocuments: [], }; -const suggestion = { +const suggestion: CurationSuggestion = { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2', '3'], + status: 'applied', }; const suggestedPromotedDocuments = [ @@ -186,6 +189,7 @@ describe('CurationSuggestionLogic', () => { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2', '3'], + status: 'applied', }, // Note that these were re-ordered to match the 'promoted' list above, and since document // 3 was not found it is not included in this list diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx index 52fbee90fe31a..5eac38b88937c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx @@ -18,7 +18,7 @@ interface Props { export const ResultActions: React.FC = ({ actions }) => { return ( - {actions.map(({ onClick, title, iconType, iconColor }) => ( + {actions.map(({ onClick, title, iconType, iconColor, disabled }) => ( = ({ actions }) => { color={iconColor ? iconColor : 'primary'} aria-label={title} title={title} + disabled={disabled} /> ))} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts index 4be3eb137177b..d9f1bb394778e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts @@ -41,4 +41,5 @@ export interface ResultAction { title: string; iconType: string; iconColor?: EuiButtonIconColor; + disabled?: boolean; } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts index e6bfaa4a9cca2..2bdcfb9fe9d58 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts @@ -38,6 +38,35 @@ describe('search relevance insights routes', () => { }); }); + describe('PUT /internal/app_search/engines/{name}/search_relevance_suggestions', () => { + const mockRouter = new MockRouter({ + method: 'put', + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + }); + + beforeEach(() => { + registerSearchRelevanceSuggestionsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { + query: 'some query', + type: 'curation', + status: 'applied', + }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }); + }); + }); + describe('GET /internal/app_search/engines/{name}/search_relevance_suggestions/settings', () => { const mockRouter = new MockRouter({ method: 'get', diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts index c6fa108a5629e..8b3b204c24d70 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts @@ -39,6 +39,20 @@ export function registerSearchRelevanceSuggestionsRoutes({ }) ); + router.put( + skipBodyValidation({ + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }), + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }) + ); + router.get( { path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dcbb8ce26ee4a..62e35bf2aae5f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9346,11 +9346,9 @@ "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryButtonLabel": "クエリを管理", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryDescription": "このキュレーションのクエリを編集、追加、削除します。", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryTitle": "クエリを管理", - "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription": "表示するオーガニック結果はありません。上記のアクティブなクエリを追加または変更します。", "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.title": "\"{currentQuery}\"の上位のオーガニックドキュメント", "xpack.enterpriseSearch.appSearch.engine.curations.overview.title": "キュレーションされた結果", "xpack.enterpriseSearch.appSearch.engine.curations.promoteButtonLabel": "この結果を昇格", - "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description": "昇格された結果はオーガニック結果の前に表示されます。ドキュメントを並べ替えることができます。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription": "以下のオーガニック結果からドキュメントにスターを付けるか、手動で結果を検索して昇格します。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel": "すべて降格", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title": "昇格されたドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 87c96d1efe48d..2f58cf9c10abf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9438,11 +9438,9 @@ "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryButtonLabel": "管理查询", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryDescription": "编辑、添加或移除此策展的查询。", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryTitle": "管理查询", - "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription": "没有要显示的有机结果。在上面添加或更改活动查询。", "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.title": "“{currentQuery}”的排名靠前有机文档", "xpack.enterpriseSearch.appSearch.engine.curations.overview.title": "已策展结果", "xpack.enterpriseSearch.appSearch.engine.curations.promoteButtonLabel": "提升此结果", - "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description": "提升结果显示在有机结果之前。可以重新排列文档。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription": "使用星号标记来自下面有机结果的文档或手动搜索或提升结果。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel": "全部降低", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title": "提升文档",