[App Search] Detail Page for Automated Curations (#113550) (#113861)

Co-authored-by: Byron Hulcher <byronhulcher@gmail.com>
This commit is contained in:
Kibana Machine 2021-10-04 20:26:14 -04:00 committed by GitHub
parent 2111eddde0
commit 85ef6207ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 697 additions and 177 deletions

View file

@ -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',
},
],
};

View file

@ -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;
}

View file

@ -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' }
);

View file

@ -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(<AutomatedCuration />);
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(<AutomatedCuration />);
expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' });
});
it('displays the query in the title with a badge', () => {
const wrapper = shallow(<AutomatedCuration />);
const pageTitle = shallow(<div>{getPageTitle(wrapper)}</div>);
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(<AutomatedCuration />);
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();
});
});
});

View file

@ -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 (
<AppSearchPageTemplate
pageChrome={getCurationsBreadcrumbs([queries.join(', ')])}
pageHeader={{
pageTitle: (
<>
{activeQuery}{' '}
<EuiBadge iconType={AutomatedIcon} color="accent">
{AUTOMATED_LABEL}
</EuiBadge>
</>
),
rightSideItems: [
<EuiButton
color="primary"
fill
iconType="exportAction"
onClick={() => {
if (window.confirm(CONVERT_TO_MANUAL_CONFIRMATION)) convertToManual();
}}
>
{COVERT_TO_MANUAL_BUTTON_LABEL}
</EuiButton>,
],
}}
isLoading={dataLoading}
>
<PromotedDocuments />
<EuiSpacer />
<OrganicDocuments />
</AppSearchPageTemplate>
);
};

View file

@ -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(<Curation />);
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(<Curation />);
expect(wrapper.find(AddResultFlyout)).toHaveLength(1);
});
it('initializes CurationLogic with a curationId prop from URL param', () => {
mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' });
shallow(<Curation />);
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(<Curation />);
@ -76,31 +49,17 @@ describe('Curation', () => {
expect(actions.loadCuration).toHaveBeenCalledTimes(2);
});
describe('restore defaults button', () => {
let restoreDefaultsButton: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
it('renders a view for automated curations', () => {
setMockValues({ isAutomated: true });
const wrapper = shallow(<Curation />);
beforeAll(() => {
const wrapper = shallow(<Curation />);
restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0);
expect(wrapper.is(AutomatedCuration)).toBe(true);
});
confirmSpy = jest.spyOn(window, 'confirm');
});
it('renders a view for manual curations', () => {
setMockValues({ isAutomated: false });
const wrapper = shallow(<Curation />);
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();
});
expect(wrapper.is(ManualCuration)).toBe(true);
});
});

View file

@ -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 (
<AppSearchPageTemplate
pageChrome={getCurationsBreadcrumbs([queries.join(', ')])}
pageHeader={{
pageTitle: MANAGE_CURATION_TITLE,
rightSideItems: [
<EuiButton
color="danger"
onClick={() => {
if (window.confirm(RESTORE_CONFIRMATION)) resetCuration();
}}
>
{RESTORE_DEFAULTS_BUTTON_LABEL}
</EuiButton>,
],
}}
isLoading={dataLoading}
>
<EuiFlexGroup alignItems="flexEnd" gutterSize="xl" responsive={false}>
<EuiFlexItem>
<ActiveQuerySelect />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ManageQueriesModal />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<PromotedDocuments />
<EuiSpacer />
<OrganicDocuments />
<EuiSpacer />
<HiddenDocuments />
{isFlyoutOpen && <AddResultFlyout />}
</AppSearchPageTemplate>
);
return isAutomated ? <AutomatedCuration /> : <ManualCuration />;
};

View file

@ -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' });

View file

@ -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<MakeLogicType<CurationValues, CurationActions, CurationProps>>({
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<MakeLogicType<CurationValues, CurationActions,
},
],
}),
selectors: ({ selectors }) => ({
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;

View file

@ -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(<OrganicDocuments />);
expect(wrapper.find(DataPanel).prop('subtitle')).toContain('Promote results');
});
it('renders a loading state', () => {
setMockValues({ ...values, organicDocumentsLoading: true });
const wrapper = shallow(<OrganicDocuments />);
@ -63,11 +73,21 @@ describe('OrganicDocuments', () => {
expect(wrapper.find(EuiLoadingContent)).toHaveLength(1);
});
it('renders an empty state', () => {
setMockValues({ ...values, curation: { organic: [] } });
const wrapper = shallow(<OrganicDocuments />);
describe('empty state', () => {
it('renders', () => {
setMockValues({ ...values, curation: { organic: [] } });
const wrapper = shallow(<OrganicDocuments />);
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(<OrganicDocuments />);
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(<OrganicDocuments />);
const result = wrapper.find(CurationResult).first();
expect(result.prop('actions')).toEqual([]);
});
});
});

View file

@ -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 = () => {
)}
</h2>
}
subtitle={RESULT_ACTIONS_DIRECTIONS}
subtitle={!isAutomated && RESULT_ACTIONS_DIRECTIONS}
>
{hasDocuments ? (
documents.map((document: Result) => (
<CurationResult
result={document}
key={document.id.raw}
actions={[
{
...HIDE_DOCUMENT_ACTION,
onClick: () => 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 ? (
<EuiLoadingContent lines={5} />
) : (
<EuiEmptyPrompt
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription',
{
defaultMessage:
'No organic results to display. Add or change the active query above.',
}
)}
body={
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.description"
defaultMessage="No organic results to display.{manualDescription}"
values={{
manualDescription: !isAutomated && (
<>
{' '}
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.manualDescription"
defaultMessage="Add or change the active query above."
/>
</>
),
}}
/>
}
/>
)}
</DataPanel>

View file

@ -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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
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(<PromotedDocuments />);
wrapper.find(EuiDragDropContext).simulate('dragEnd', {

View file

@ -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 = () => {
)}
</h2>
}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description',
{
defaultMessage:
'Promoted results appear before organic results. Documents can be re-ordered.',
}
)}
subtitle={
isAutomated ? (
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.automatedDescription"
defaultMessage="This curation is being managed by App Search"
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.manualDescription"
defaultMessage="Promoted results appear before organic results. Documents can be re-ordered."
/>
)
}
action={
!isAutomated &&
hasDocuments && (
<EuiFlexGroup gutterSize="s" responsive={false} wrap>
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty onClick={clearPromotedIds} iconType="menuDown" size="s">
<EuiButtonEmpty
onClick={clearPromotedIds}
iconType="menuDown"
size="s"
disabled={isAutomated}
>
{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) => (
<CurationResult
key={document.id}
result={convertToResultFormat(document)}
actions={[
{
...DEMOTE_DOCUMENT_ACTION,
onClick: () => removePromotedId(document.id),
},
]}
actions={
isAutomated
? []
: [
{
...DEMOTE_DOCUMENT_ACTION,
onClick: () => removePromotedId(document.id),
},
]
}
dragHandleProps={provided.dragHandleProps}
/>
)}
@ -109,13 +127,22 @@ export const PromotedDocuments: React.FC = () => {
</EuiDragDropContext>
) : (
<EuiEmptyPrompt
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription',
{
defaultMessage:
'Star documents from the organic results below, or search and promote a result manually.',
}
)}
body={
isAutomated
? i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.automatedEmptyDescription',
{
defaultMessage: "We haven't identified any documents to promote",
}
)
: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription',
{
defaultMessage:
'Star documents from the organic results below, or search and promote a result manually.',
}
)
}
actions={<AddResultButton />}
/>
)}

View file

@ -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(<ManualCuration />);
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(<ManualCuration />);
expect(wrapper.find(AddResultFlyout)).toHaveLength(1);
});
it('initializes CurationLogic with a curationId prop from URL param', () => {
mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' });
shallow(<ManualCuration />);
expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' });
});
describe('restore defaults button', () => {
let restoreDefaultsButton: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
beforeAll(() => {
const wrapper = shallow(<ManualCuration />);
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();
});
});
});

View file

@ -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 (
<AppSearchPageTemplate
pageChrome={getCurationsBreadcrumbs([queries.join(', ')])}
pageHeader={{
pageTitle: MANAGE_CURATION_TITLE,
rightSideItems: [
<EuiButton
color="danger"
onClick={() => {
if (window.confirm(RESTORE_CONFIRMATION)) resetCuration();
}}
>
{RESTORE_DEFAULTS_BUTTON_LABEL}
</EuiButton>,
],
}}
isLoading={dataLoading}
>
<EuiFlexGroup alignItems="flexEnd" gutterSize="xl" responsive={false}>
<EuiFlexItem>
<ActiveQuerySelect />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ManageQueriesModal />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<PromotedDocuments />
<EuiSpacer />
<OrganicDocuments />
<EuiSpacer />
<HiddenDocuments />
{isFlyoutOpen && <AddResultFlyout />}
</AppSearchPageTemplate>
);
};

View file

@ -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(<AddResultButton />);
});
it('renders', () => {
expect(wrapper.find(EuiButton)).toHaveLength(1);
const wrapper = shallow(<AddResultButton />);
expect(wrapper.is(EuiButton)).toBe(true);
});
it('opens the add result flyout on click', () => {
setMockActions(actions);
const wrapper = shallow(<AddResultButton />);
wrapper.find(EuiButton).simulate('click');
expect(actions.openFlyout).toHaveBeenCalled();
});
it('is disbled when the curation is automated', () => {
setMockValues({ ...values, isAutomated: true });
const wrapper = shallow(<AddResultButton />);
expect(wrapper.find(EuiButton).prop('disabled')).toBe(true);
});
});

View file

@ -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 (
<EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" fill>
<EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" fill disabled={isAutomated}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.buttonLabel', {
defaultMessage: 'Add result manually',
})}

View file

@ -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 {

View file

@ -18,7 +18,7 @@ interface Props {
export const ResultActions: React.FC<Props> = ({ actions }) => {
return (
<EuiFlexGroup gutterSize="s" responsive={false}>
{actions.map(({ onClick, title, iconType, iconColor }) => (
{actions.map(({ onClick, title, iconType, iconColor, disabled }) => (
<EuiFlexItem key={title} grow={false}>
<EuiButtonIcon
iconType={iconType}
@ -26,6 +26,7 @@ export const ResultActions: React.FC<Props> = ({ actions }) => {
color={iconColor ? iconColor : 'primary'}
aria-label={title}
title={title}
disabled={disabled}
/>
</EuiFlexItem>
))}

View file

@ -41,4 +41,5 @@ export interface ResultAction {
title: string;
iconType: string;
iconColor?: EuiButtonIconColor;
disabled?: boolean;
}

View file

@ -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',

View file

@ -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',

View file

@ -9505,11 +9505,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": "昇格されたドキュメント",

View file

@ -9600,11 +9600,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": "提升文档",