[App Search] General UX Improvements for Curations and Suggestions (#114213) (#114312)

Co-authored-by: Byron Hulcher <byronhulcher@gmail.com>
This commit is contained in:
Kibana Machine 2021-10-07 14:03:20 -04:00 committed by GitHub
parent 654b7310d9
commit a3e390f924
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 418 additions and 200 deletions

View file

@ -68,7 +68,7 @@ const columns: Array<EuiBasicTableColumn<CurationSuggestion>> = [
field: 'promoted',
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsTable.column.promotedDocumentsTableHeader',
{ defaultMessage: 'Promoted documents' }
{ defaultMessage: 'Promoted results' }
),
render: (promoted: string[]) => <span>{promoted.length}</span>,
},

View file

@ -34,11 +34,11 @@ export const QUERY_INPUTS_PLACEHOLDER = i18n.translate(
{ defaultMessage: 'Enter a query' }
);
export const DELETE_MESSAGE = i18n.translate(
export const DELETE_CONFIRMATION_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.deleteConfirmation',
{ defaultMessage: 'Are you sure you want to remove this curation?' }
);
export const SUCCESS_MESSAGE = i18n.translate(
export const DELETE_SUCCESS_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.deleteSuccessMessage',
{ defaultMessage: 'Your curation was deleted' }
);

View file

@ -14,7 +14,7 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiBadge, EuiTab } from '@elastic/eui';
import { EuiBadge, EuiButton, EuiLoadingSpinner, EuiTab } from '@elastic/eui';
import { getPageHeaderActions, getPageHeaderTabs, getPageTitle } from '../../../../test_helpers';
@ -25,6 +25,7 @@ import { AppSearchPageTemplate } from '../../layout';
import { AutomatedCuration } from './automated_curation';
import { CurationLogic } from './curation_logic';
import { DeleteCurationButton } from './delete_curation_button';
import { PromotedDocuments, OrganicDocuments } from './documents';
describe('AutomatedCuration', () => {
@ -33,6 +34,8 @@ describe('AutomatedCuration', () => {
queries: ['query A', 'query B'],
isFlyoutOpen: false,
curation: {
promoted: [],
hidden: [],
suggestion: {
status: 'applied',
},
@ -89,13 +92,29 @@ describe('AutomatedCuration', () => {
expect(pageTitle.find(EuiBadge)).toHaveLength(1);
});
it('displays a spinner in the title when loading', () => {
setMockValues({ ...values, dataLoading: true });
const wrapper = shallow(<AutomatedCuration />);
const pageTitle = shallow(<div>{getPageTitle(wrapper)}</div>);
expect(pageTitle.find(EuiLoadingSpinner)).toHaveLength(1);
});
it('contains a button to delete the curation', () => {
const wrapper = shallow(<AutomatedCuration />);
const pageHeaderActions = getPageHeaderActions(wrapper);
expect(pageHeaderActions.find(DeleteCurationButton)).toHaveLength(1);
});
describe('convert to manual button', () => {
let convertToManualButton: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
beforeAll(() => {
const wrapper = shallow(<AutomatedCuration />);
convertToManualButton = getPageHeaderActions(wrapper).childAt(0);
convertToManualButton = getPageHeaderActions(wrapper).find(EuiButton);
confirmSpy = jest.spyOn(window, 'confirm');
});
@ -107,12 +126,14 @@ describe('AutomatedCuration', () => {
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

@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { EuiSpacer, EuiButton, EuiBadge } from '@elastic/eui';
import { EuiButton, EuiBadge, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AppSearchPageTemplate } from '../../layout';
import { AutomatedIcon } from '../components/automated_icon';
@ -24,22 +24,25 @@ import { getCurationsBreadcrumbs } from '../utils';
import { HIDDEN_DOCUMENTS_TITLE, PROMOTED_DOCUMENTS_TITLE } from './constants';
import { CurationLogic } from './curation_logic';
import { DeleteCurationButton } from './delete_curation_button';
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);
const { activeQuery, dataLoading, queries, curation } = useValues(logic);
// This tab group is meant to visually mirror the dynamic group of tags in the ManualCuration component
const pageTabs = [
{
label: PROMOTED_DOCUMENTS_TITLE,
append: <EuiBadge>{curation.promoted.length}</EuiBadge>,
isSelected: true,
},
{
label: HIDDEN_DOCUMENTS_TITLE,
append: <EuiBadge isDisabled>0</EuiBadge>,
isSelected: false,
disabled: true,
},
@ -51,30 +54,36 @@ export const AutomatedCuration: React.FC = () => {
pageHeader={{
pageTitle: (
<>
{activeQuery}{' '}
{dataLoading ? <EuiLoadingSpinner size="l" /> : 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>,
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<DeleteCurationButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
fill
iconType="exportAction"
onClick={() => {
if (window.confirm(CONVERT_TO_MANUAL_CONFIRMATION)) convertToManual();
}}
>
{COVERT_TO_MANUAL_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>,
],
tabs: pageTabs,
}}
isLoading={dataLoading}
>
<PromotedDocuments />
<EuiSpacer />
<OrganicDocuments />
</AppSearchPageTemplate>
);

View file

@ -9,10 +9,10 @@ import { i18n } from '@kbn/i18n';
export const PROMOTED_DOCUMENTS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title',
{ defaultMessage: 'Promoted documents' }
{ defaultMessage: 'Promoted results' }
);
export const HIDDEN_DOCUMENTS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.title',
{ defaultMessage: 'Hidden documents' }
{ defaultMessage: 'Hidden results' }
);

View file

@ -21,7 +21,7 @@ describe('CurationLogic', () => {
const { mount } = new LogicMounter(CurationLogic);
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers;
const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers;
const MOCK_CURATION_RESPONSE = {
id: 'cur-123456789',
@ -249,23 +249,6 @@ describe('CurationLogic', () => {
});
});
describe('resetCuration', () => {
it('should clear promotedIds & hiddenIds & set dataLoading to true', () => {
mount({ promotedIds: ['hello'], hiddenIds: ['world'] });
CurationLogic.actions.resetCuration();
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
promotedIds: [],
promotedDocumentsLoading: true,
hiddenIds: [],
hiddenDocumentsLoading: true,
});
});
});
describe('onSelectPageTab', () => {
it('should set the selected page tab', () => {
mount({
@ -336,6 +319,33 @@ describe('CurationLogic', () => {
});
});
describe('deleteCuration', () => {
it('should make an API call and navigate to the curations page', async () => {
http.delete.mockReturnValueOnce(Promise.resolve());
mount({}, { curationId: 'cur-123456789' });
jest.spyOn(CurationLogic.actions, 'onCurationLoad');
CurationLogic.actions.deleteCuration();
await nextTick();
expect(http.delete).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/curations/cur-123456789'
);
expect(flashSuccessToast).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations');
});
it('flashes any errors', async () => {
http.delete.mockReturnValueOnce(Promise.reject('error'));
mount({}, { curationId: 'cur-404' });
CurationLogic.actions.deleteCuration();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('loadCuration', () => {
it('should set dataLoading state', () => {
mount({ dataLoading: false }, { curationId: 'cur-123456789' });

View file

@ -7,11 +7,16 @@
import { kea, MakeLogicType } from 'kea';
import { clearFlashMessages, flashAPIErrors } from '../../../../shared/flash_messages';
import {
clearFlashMessages,
flashAPIErrors,
flashSuccessToast,
} from '../../../../shared/flash_messages';
import { HttpLogic } from '../../../../shared/http';
import { KibanaLogic } from '../../../../shared/kibana';
import { ENGINE_CURATIONS_PATH } from '../../../routes';
import { EngineLogic, generateEnginePath } from '../../engine';
import { DELETE_SUCCESS_MESSAGE } from '../constants';
import { Curation } from '../types';
import { addDocument, removeDocument } from '../utils';
@ -35,6 +40,7 @@ interface CurationValues {
interface CurationActions {
convertToManual(): void;
deleteCuration(): void;
loadCuration(): void;
onCurationLoad(curation: Curation): { curation: Curation };
updateCuration(): void;
@ -48,7 +54,6 @@ interface CurationActions {
addHiddenId(id: string): { id: string };
removeHiddenId(id: string): { id: string };
clearHiddenIds(): void;
resetCuration(): void;
onSelectPageTab(pageTab: CurationPageTabs): { pageTab: CurationPageTabs };
}
@ -60,6 +65,7 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
path: ['enterprise_search', 'app_search', 'curation_logic'],
actions: () => ({
convertToManual: true,
deleteCuration: true,
loadCuration: true,
onCurationLoad: (curation) => ({ curation }),
updateCuration: true,
@ -73,7 +79,6 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
addHiddenId: (id) => ({ id }),
removeHiddenId: (id) => ({ id }),
clearHiddenIds: true,
resetCuration: true,
onSelectPageTab: (pageTab) => ({ pageTab }),
}),
reducers: () => ({
@ -81,7 +86,6 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
true,
{
loadCuration: () => true,
resetCuration: () => true,
onCurationLoad: () => false,
onCurationError: () => false,
},
@ -204,6 +208,21 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
flashAPIErrors(e);
}
},
deleteCuration: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const { navigateToUrl } = KibanaLogic.values;
try {
await http.delete(
`/internal/app_search/engines/${engineName}/curations/${props.curationId}`
);
navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH));
flashSuccessToast(DELETE_SUCCESS_MESSAGE);
} catch (e) {
flashAPIErrors(e);
}
},
loadCuration: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
@ -260,9 +279,5 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
addHiddenId: () => actions.updateCuration(),
removeHiddenId: () => actions.updateCuration(),
clearHiddenIds: () => actions.updateCuration(),
resetCuration: () => {
actions.clearPromotedIds();
actions.clearHiddenIds();
},
}),
});

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 '../../../../__mocks__/shallow_useeffect.mock';
import { setMockActions } 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 { EuiButton } from '@elastic/eui';
jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() }));
import { DeleteCurationButton } from './delete_curation_button';
describe('DeleteCurationButton', () => {
const actions = {
deleteCuration: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockActions(actions);
mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' });
});
it('renders', () => {
const wrapper = shallow(<DeleteCurationButton />);
expect(wrapper.is(EuiButton)).toBe(true);
});
describe('restore defaults button', () => {
let wrapper: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
beforeAll(() => {
wrapper = shallow(<DeleteCurationButton />);
confirmSpy = jest.spyOn(window, 'confirm');
});
afterAll(() => {
confirmSpy.mockRestore();
});
it('resets the curation upon user confirmation', () => {
confirmSpy.mockReturnValueOnce(true);
wrapper.simulate('click');
expect(actions.deleteCuration).toHaveBeenCalled();
});
it('does not reset the curation if the user cancels', () => {
confirmSpy.mockReturnValueOnce(false);
wrapper.simulate('click');
expect(actions.deleteCuration).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { useActions } from 'kea';
import { EuiButton } from '@elastic/eui';
import { DELETE_BUTTON_LABEL } from '../../../../shared/constants';
import { DELETE_CONFIRMATION_MESSAGE } from '../constants';
import { CurationLogic } from '.';
export const DeleteCurationButton: React.FC = () => {
const { curationId } = useParams() as { curationId: string };
const { deleteCuration } = useActions(CurationLogic({ curationId }));
return (
<EuiButton
color="danger"
iconType="trash"
onClick={() => {
if (window.confirm(DELETE_CONFIRMATION_MESSAGE)) deleteCuration();
}}
>
{DELETE_BUTTON_LABEL}
</EuiButton>
);
};

View file

@ -11,7 +11,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui';
import { EuiEmptyPrompt, EuiButtonEmpty, EuiBadge } from '@elastic/eui';
import { DataPanel } from '../../../data_panel';
import { CurationResult } from '../results';
@ -48,6 +48,14 @@ describe('HiddenDocuments', () => {
expect(wrapper.find(CurationResult)).toHaveLength(5);
});
it('displays the number of documents in a badge', () => {
const wrapper = shallow(<HiddenDocuments />);
const Icon = wrapper.prop('iconType');
const iconWrapper = shallow(<Icon />);
expect(iconWrapper.find(EuiBadge).prop('children')).toEqual(5);
});
it('renders an empty state & hides the panel actions when empty', () => {
setMockValues({ ...values, curation: { hidden: [] } });
const wrapper = shallow(<HiddenDocuments />);

View file

@ -9,7 +9,7 @@ import React from 'react';
import { useValues, useActions } from 'kea';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiEmptyPrompt } from '@elastic/eui';
import { EuiBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataPanel } from '../../../data_panel';
@ -26,39 +26,37 @@ export const HiddenDocuments: React.FC = () => {
const documents = curation.hidden;
const hasDocuments = documents.length > 0;
const CountBadge: React.FC = () => <EuiBadge color="accent">{documents.length}</EuiBadge>;
return (
<DataPanel
filled
iconType="eyeClosed"
iconType={CountBadge}
title={<h2>{HIDDEN_DOCUMENTS_TITLE}</h2>}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.description',
{ defaultMessage: 'Hidden documents will not appear in organic results.' }
)}
action={
hasDocuments && (
<EuiFlexGroup gutterSize="s" responsive={false} wrap>
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty onClick={clearHiddenIds} iconType="menuUp" size="s">
<EuiButtonEmpty onClick={clearHiddenIds} size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.removeAllButtonLabel',
{ defaultMessage: 'Restore all' }
{ defaultMessage: 'Unhide all' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
</EuiFlexGroup>
)
}
isLoading={hiddenDocumentsLoading}
>
{hasDocuments ? (
documents.map((document) => (
documents.map((document, index) => (
<CurationResult
key={document.id}
result={convertToResultFormat(document)}
index={index}
actions={[
{
...SHOW_DOCUMENT_ACTION,

View file

@ -59,13 +59,6 @@ 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 />);
@ -74,13 +67,20 @@ describe('OrganicDocuments', () => {
});
describe('empty state', () => {
it('renders', () => {
it('renders when organic results is empty', () => {
setMockValues({ ...values, curation: { organic: [] } });
const wrapper = shallow(<OrganicDocuments />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
it('renders when organic results is undefined', () => {
setMockValues({ ...values, curation: { organic: undefined } });
const wrapper = shallow(<OrganicDocuments />);
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 />);

View file

@ -13,14 +13,12 @@ import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { LeafIcon } from '../../../../../shared/icons';
import { DataPanel } from '../../../data_panel';
import { Result } from '../../../result/types';
import {
RESULT_ACTIONS_DIRECTIONS,
PROMOTE_DOCUMENT_ACTION,
HIDE_DOCUMENT_ACTION,
} from '../../constants';
import { PROMOTE_DOCUMENT_ACTION, HIDE_DOCUMENT_ACTION } from '../../constants';
import { CurationLogic } from '../curation_logic';
import { CurationResult } from '../results';
@ -28,14 +26,13 @@ export const OrganicDocuments: React.FC = () => {
const { addPromotedId, addHiddenId } = useActions(CurationLogic);
const { curation, activeQuery, isAutomated, organicDocumentsLoading } = useValues(CurationLogic);
const documents = curation.organic;
const documents = curation.organic || [];
const hasDocuments = documents.length > 0 && !organicDocumentsLoading;
const currentQuery = activeQuery;
return (
<DataPanel
filled
iconType="search"
iconType={LeafIcon}
title={
<h2>
{i18n.translate(
@ -47,12 +44,12 @@ export const OrganicDocuments: React.FC = () => {
)}
</h2>
}
subtitle={!isAutomated && RESULT_ACTIONS_DIRECTIONS}
>
{hasDocuments ? (
documents.map((document: Result) => (
documents.map((document: Result, index) => (
<CurationResult
result={document}
index={index}
key={document.id.raw}
actions={
isAutomated

View file

@ -10,7 +10,14 @@ import React from 'react';
import { shallow } from 'enzyme';
import { EuiDragDropContext, EuiDraggable, EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui';
import {
EuiDragDropContext,
EuiDraggable,
EuiEmptyPrompt,
EuiButtonEmpty,
EuiBadge,
EuiTextColor,
} from '@elastic/eui';
import { mountWithIntl } from '../../../../../test_helpers';
import { DataPanel } from '../../../data_panel';
@ -47,6 +54,14 @@ describe('PromotedDocuments', () => {
return draggableWrapper.renderProp('children')({}, {}, {});
};
it('displays the number of documents in a badge', () => {
const wrapper = shallow(<PromotedDocuments />);
const Icon = wrapper.prop('iconType');
const iconWrapper = shallow(<Icon />);
expect(iconWrapper.find(EuiBadge).prop('children')).toEqual(4);
});
it('renders a list of draggable promoted documents', () => {
const wrapper = shallow(<PromotedDocuments />);
@ -57,22 +72,6 @@ describe('PromotedDocuments', () => {
});
});
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: [] } });
@ -90,18 +89,12 @@ describe('PromotedDocuments', () => {
});
});
it('hides the panel actions when empty', () => {
setMockValues({ ...values, curation: { promoted: [] } });
const wrapper = shallow(<PromotedDocuments />);
expect(wrapper.find(DataPanel).prop('action')).toBe(false);
});
it('hides the panel actions when the curation is automated', () => {
it('shows a message when the curation is automated', () => {
setMockValues({ ...values, isAutomated: true });
const wrapper = shallow(<PromotedDocuments />);
const panelAction = shallow(wrapper.find(DataPanel).prop('action') as React.ReactElement);
expect(wrapper.find(DataPanel).prop('action')).toBe(false);
expect(panelAction.find(EuiTextColor)).toHaveLength(1);
});
it('renders a loading state', () => {
@ -136,6 +129,13 @@ describe('PromotedDocuments', () => {
expect(actions.clearPromotedIds).toHaveBeenCalled();
});
it('hides the demote all button when there are on promoted results', () => {
setMockValues({ ...values, curation: { promoted: [] } });
const wrapper = shallow(<PromotedDocuments />);
expect(wrapper.find(DataPanel).prop('action')).toEqual(false);
});
describe('dragging', () => {
it('calls setPromotedIds with the reordered list when users are done dragging', () => {
const wrapper = shallow(<PromotedDocuments />);

View file

@ -19,9 +19,10 @@ import {
EuiDroppable,
EuiDraggable,
euiDragDropReorder,
EuiBadge,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DataPanel } from '../../../data_panel';
@ -43,45 +44,45 @@ export const PromotedDocuments: React.FC = () => {
}
};
const CountBadge: React.FC = () => <EuiBadge color="accent">{documents.length}</EuiBadge>;
return (
<DataPanel
filled
iconType="starFilled"
iconType={CountBadge}
title={<h2>{PROMOTED_DOCUMENTS_TITLE}</h2>}
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"
disabled={isAutomated}
>
isAutomated ? (
<EuiText color="subdued" size="s">
<p>
<em>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel',
{ defaultMessage: 'Demote all' }
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.managedByAppSearchDescription',
{ defaultMessage: 'This curation is being automated by App Search' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</em>
</p>
</EuiText>
) : (
hasDocuments && (
<EuiFlexGroup gutterSize="s" responsive={false} wrap>
<EuiFlexItem>
<EuiButtonEmpty
onClick={clearPromotedIds}
color="danger"
size="s"
disabled={isAutomated}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel',
{ defaultMessage: 'Demote all' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
</EuiFlexGroup>
)
)
}
isLoading={promotedDocumentsLoading}
@ -89,9 +90,9 @@ export const PromotedDocuments: React.FC = () => {
{hasDocuments ? (
<EuiDragDropContext onDragEnd={reorderPromotedIds}>
<EuiDroppable droppableId="PromotedDocuments" spacing="m">
{documents.map((document, i: number) => (
{documents.map((document, index) => (
<EuiDraggable
index={i}
index={index}
key={document.id}
draggableId={document.id}
customDragHandle
@ -101,6 +102,7 @@ export const PromotedDocuments: React.FC = () => {
{(provided) => (
<CurationResult
key={document.id}
index={index}
result={convertToResultFormat(document)}
actions={
isAutomated

View file

@ -21,8 +21,10 @@ import { getPageTitle, getPageHeaderActions, getPageHeaderTabs } from '../../../
jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() }));
import { CurationLogic } from './curation_logic';
import { DeleteCurationButton } from './delete_curation_button';
import { PromotedDocuments, HiddenDocuments } from './documents';
import { ManualCuration } from './manual_curation';
import { ActiveQuerySelect, ManageQueriesModal } from './queries';
import { AddResultFlyout } from './results';
import { SuggestedDocumentsCallout } from './suggested_documents_callout';
@ -32,9 +34,13 @@ describe('ManualCuration', () => {
queries: ['query A', 'query B'],
isFlyoutOpen: false,
selectedPageTab: 'promoted',
curation: {
promoted: [],
hidden: [],
},
};
const actions = {
resetCuration: jest.fn(),
deleteCuration: jest.fn(),
onSelectPageTab: jest.fn(),
};
@ -108,31 +114,26 @@ describe('ManualCuration', () => {
expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' });
});
describe('restore defaults button', () => {
let restoreDefaultsButton: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
describe('page header actions', () => {
let pageHeaderActions: ShallowWrapper;
beforeAll(() => {
const wrapper = shallow(<ManualCuration />);
restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0);
confirmSpy = jest.spyOn(window, 'confirm');
pageHeaderActions = getPageHeaderActions(wrapper);
});
afterAll(() => {
confirmSpy.mockRestore();
it('contains a button to manage queries and an active query selector', () => {
expect(pageHeaderActions.find(ManageQueriesModal)).toHaveLength(1);
});
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();
it('contains a button to delete the curation', () => {
expect(pageHeaderActions.find(DeleteCurationButton)).toHaveLength(1);
});
});
it('contains an active query selector', () => {
const wrapper = shallow(<ManualCuration />);
expect(wrapper.find(ActiveQuerySelect)).toHaveLength(1);
});
});

View file

@ -10,15 +10,15 @@ import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants';
import { AppSearchPageTemplate } from '../../layout';
import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants';
import { MANAGE_CURATION_TITLE } from '../constants';
import { getCurationsBreadcrumbs } from '../utils';
import { PROMOTED_DOCUMENTS_TITLE, HIDDEN_DOCUMENTS_TITLE } from './constants';
import { CurationLogic } from './curation_logic';
import { DeleteCurationButton } from './delete_curation_button';
import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents';
import { ActiveQuerySelect, ManageQueriesModal } from './queries';
import { AddResultLogic, AddResultFlyout } from './results';
@ -26,18 +26,22 @@ import { SuggestedDocumentsCallout } from './suggested_documents_callout';
export const ManualCuration: React.FC = () => {
const { curationId } = useParams() as { curationId: string };
const { onSelectPageTab, resetCuration } = useActions(CurationLogic({ curationId }));
const { dataLoading, queries, selectedPageTab } = useValues(CurationLogic({ curationId }));
const { onSelectPageTab } = useActions(CurationLogic({ curationId }));
const { dataLoading, queries, selectedPageTab, curation } = useValues(
CurationLogic({ curationId })
);
const { isFlyoutOpen } = useValues(AddResultLogic);
const pageTabs = [
{
label: PROMOTED_DOCUMENTS_TITLE,
append: <EuiBadge>{curation.promoted.length}</EuiBadge>,
isSelected: selectedPageTab === 'promoted',
onClick: () => onSelectPageTab('promoted'),
},
{
label: HIDDEN_DOCUMENTS_TITLE,
append: <EuiBadge>{curation.hidden.length}</EuiBadge>,
isSelected: selectedPageTab === 'hidden',
onClick: () => onSelectPageTab('hidden'),
},
@ -49,32 +53,23 @@ export const ManualCuration: React.FC = () => {
pageHeader={{
pageTitle: MANAGE_CURATION_TITLE,
rightSideItems: [
<EuiButton
color="danger"
onClick={() => {
if (window.confirm(RESTORE_CONFIRMATION)) resetCuration();
}}
>
{RESTORE_DEFAULTS_BUTTON_LABEL}
</EuiButton>,
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<DeleteCurationButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ManageQueriesModal />
</EuiFlexItem>
</EuiFlexGroup>,
],
tabs: pageTabs,
}}
isLoading={dataLoading}
>
<SuggestedDocumentsCallout />
<EuiFlexGroup alignItems="flexEnd" gutterSize="xl" responsive={false}>
<EuiFlexItem>
<ActiveQuerySelect />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ManageQueriesModal />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<ActiveQuerySelect />
{selectedPageTab === 'promoted' && <SuggestedDocumentsCallout />}
{selectedPageTab === 'promoted' && <PromotedDocuments />}
{selectedPageTab === 'hidden' && <HiddenDocuments />}
<EuiSpacer />
<OrganicDocuments />
{isFlyoutOpen && <AddResultFlyout />}

View file

@ -23,6 +23,12 @@ export const ActiveQuerySelect: React.FC = () => {
label={i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.activeQueryLabel', {
defaultMessage: 'Active query',
})}
helpText={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.activeQueryHelpText',
{
defaultMessage: 'Select a query to view the organic search results for them',
}
)}
fullWidth
>
<EuiSelect

View file

@ -36,7 +36,7 @@ export const ManageQueriesModal: React.FC = () => {
return (
<>
<EuiButton onClick={showModal} isLoading={queriesLoading}>
<EuiButton fill onClick={showModal} isLoading={queriesLoading}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.manageQueryButtonLabel',
{ defaultMessage: 'Manage queries' }

View file

@ -21,7 +21,7 @@ export const AddResultButton: React.FC = () => {
const { isAutomated } = useValues(CurationLogic);
return (
<EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" fill disabled={isAutomated}>
<EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" disabled={isAutomated}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.buttonLabel', {
defaultMessage: 'Add result manually',
})}

View file

@ -51,4 +51,16 @@ describe('CurationResult', () => {
expect(wrapper.find(Result).prop('actions')).toEqual(mockActions);
expect(wrapper.find(Result).prop('dragHandleProps')).toEqual(mockDragging);
});
it('increments the result index before passing it on', () => {
wrapper = shallow(
<CurationResult
result={mockResult}
index={5}
actions={mockActions}
dragHandleProps={mockDragging}
/>
);
expect(wrapper.find(Result).prop('resultPosition')).toEqual(6);
});
});

View file

@ -17,12 +17,13 @@ import { Result } from '../../../result';
import { Result as ResultType, ResultAction } from '../../../result/types';
interface Props {
result: ResultType;
actions: ResultAction[];
dragHandleProps?: DraggableProvidedDragHandleProps;
result: ResultType;
index?: number;
}
export const CurationResult: React.FC<Props> = ({ result, actions, dragHandleProps }) => {
export const CurationResult: React.FC<Props> = ({ actions, dragHandleProps, result, index }) => {
const {
isMetaEngine,
engine: { schema },
@ -36,6 +37,7 @@ export const CurationResult: React.FC<Props> = ({ result, actions, dragHandlePro
isMetaEngine={isMetaEngine}
schemaForTypeHighlights={schema}
dragHandleProps={dragHandleProps}
resultPosition={typeof index === 'undefined' ? undefined : index + 1}
/>
<EuiSpacer size="m" />
</>

View file

@ -20,7 +20,7 @@ import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { ENGINE_CURATION_PATH } from '../../routes';
import { EngineLogic, generateEnginePath } from '../engine';
import { DELETE_MESSAGE, SUCCESS_MESSAGE } from './constants';
import { DELETE_CONFIRMATION_MESSAGE, DELETE_SUCCESS_MESSAGE } from './constants';
import { Curation, CurationsAPIResponse } from './types';
type CurationsPageTabs = 'overview' | 'settings';
@ -102,11 +102,11 @@ export const CurationsLogic = kea<MakeLogicType<CurationsValues, CurationsAction
const { engineName } = EngineLogic.values;
clearFlashMessages();
if (window.confirm(DELETE_MESSAGE)) {
if (window.confirm(DELETE_CONFIRMATION_MESSAGE)) {
try {
await http.delete(`/internal/app_search/engines/${engineName}/curations/${id}`);
actions.loadCurations();
flashSuccessToast(SUCCESS_MESSAGE);
flashSuccessToast(DELETE_SUCCESS_MESSAGE);
} catch (e) {
flashAPIErrors(e);
}

View file

@ -23,7 +23,7 @@ export interface Curation {
queries: string[];
promoted: CurationResult[];
hidden: CurationResult[];
organic: Result[];
organic?: Result[]; // this field is missing if there are 0 results
suggestion?: CurationSuggestion;
}

View file

@ -48,6 +48,7 @@ describe('CurationResultPanel', () => {
expect(wrapper.find(Result).length).toBe(2);
expect(wrapper.find(Result).at(0).props()).toEqual({
result: results[0],
resultPosition: 1,
isMetaEngine: true,
schemaForTypeHighlights: values.engine.schema,
});

View file

@ -72,12 +72,13 @@ export const CurationResultPanel: React.FC<Props> = ({ variant, results }) => {
className={`curationResultPanel curationResultPanel--${variant}`}
>
{results.length > 0 ? (
results.map((result) => (
results.map((result, index) => (
<EuiFlexItem key={result.id.raw} style={{ width: '100%' }}>
<Result
result={result}
isMetaEngine={isMetaEngine}
schemaForTypeHighlights={engine.schema}
resultPosition={index + 1}
/>
</EuiFlexItem>
))

View file

@ -129,12 +129,13 @@ export const CurationSuggestion: React.FC = () => {
gutterSize="s"
data-test-subj="currentOrganicResults"
>
{currentOrganicResults.map((result: ResultType) => (
{currentOrganicResults.map((result: ResultType, index) => (
<EuiFlexItem grow={false} key={result.id.raw}>
<Result
result={result}
isMetaEngine={isMetaEngine}
schemaForTypeHighlights={engine.schema}
resultPosition={index + 1}
/>
</EuiFlexItem>
))}
@ -148,12 +149,13 @@ export const CurationSuggestion: React.FC = () => {
gutterSize="s"
data-test-subj="proposedOrganicResults"
>
{proposedOrganicResults.map((result: ResultType) => (
{proposedOrganicResults.map((result: ResultType, index) => (
<EuiFlexItem grow={false} key={result.id.raw}>
<Result
result={result}
isMetaEngine={isMetaEngine}
schemaForTypeHighlights={engine.schema}
resultPosition={index + 1}
/>
</EuiFlexItem>
))}

View file

@ -34,7 +34,11 @@ describe('DataPanel', () => {
wrapper.setProps({ children: 'hello world' });
expect(wrapper.find(EuiSpacer)).toHaveLength(1);
expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s');
wrapper.setProps({ filled: true });
expect(wrapper.find(EuiSpacer).prop('size')).toEqual('l');
});
describe('components', () => {

View file

@ -87,7 +87,7 @@ export const DataPanel: React.FC<Props> = ({
</EuiFlexGroup>
{children && (
<>
<EuiSpacer />
<EuiSpacer size={filled || subtitle ? 'l' : 's'} />
{children}
</>
)}

View file

@ -9,6 +9,10 @@ import React from 'react';
import { shallow } from 'enzyme';
import { EuiBadge } from '@elastic/eui';
import { mountWithIntl } from '../../../test_helpers';
import { ResultActions } from './result_actions';
import { ResultHeader } from './result_header';
@ -46,6 +50,13 @@ describe('ResultHeader', () => {
);
});
it('renders position if one is passed in', () => {
const wrapper = mountWithIntl(<ResultHeader {...props} resultPosition={5} />);
const badge = wrapper.find(EuiBadge);
expect(badge.text()).toContain('#5');
});
describe('score', () => {
it('renders score if showScore is true ', () => {
const wrapper = shallow(<ResultHeader {...props} showScore />);

View file

@ -43,7 +43,7 @@ export const ResultHeader: React.FC<Props> = ({
responsive={false}
wrap
>
{resultPosition && (
{typeof resultPosition !== 'undefined' && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
<FormattedMessage

View file

@ -6,3 +6,4 @@
*/
export { LightbulbIcon } from './lightbulb_icon';
export { LeafIcon } from './leaf_icon';

View file

@ -0,0 +1,23 @@
/*
* 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';
// TODO: This icon will be added to EUI soon - we should remove this custom SVG when once it's available in EUI
export const LeafIcon: React.FC = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className="euiIcon"
width={16}
height={16}
{...props}
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d="M15.0331 0.886954C15.2956 0.973255 15.5233 1.21266 15.5414 1.55005C15.6669 3.88393 15.361 7.34896 14.1321 10.0468C13.5157 11.3998 12.6491 12.5993 11.4483 13.3465C10.2327 14.1029 8.72706 14.3628 6.91355 13.9235C6.53012 13.8306 6.17184 13.7065 5.83884 13.5557C5.54781 13.4238 5.49404 13.047 5.72206 12.8232L5.77108 12.7753C5.92249 12.6281 6.15055 12.6009 6.34449 12.6844C6.59563 12.7926 6.86382 12.8825 7.14903 12.9516C8.73299 13.3354 9.95814 13.096 10.92 12.4974C11.8967 11.8897 12.6543 10.8786 13.222 9.63227C14.2675 7.33734 14.6079 4.37902 14.5638 2.17691C13.7629 2.93049 13.0215 3.38061 12.3014 3.63327C11.4623 3.92768 10.6938 3.93856 9.97619 3.90053C9.79372 3.89086 9.61467 3.87835 9.43764 3.86598C8.22713 3.78139 7.11038 3.70335 5.63121 4.58567C3.63527 5.77625 3.1 8.36625 4.00645 10.3814C5.40683 9.10928 7.13522 8.0625 9.00009 8.0625C9.27624 8.0625 9.50009 8.28636 9.50009 8.5625C9.50009 8.83864 9.27624 9.0625 9.00009 9.0625C7.39524 9.0625 5.79665 10.0415 4.41045 11.3724C3.03666 12.6914 1.95051 14.2809 1.37654 15.3332C1.24431 15.5756 0.940593 15.6649 0.698168 15.5327C0.455744 15.4005 0.366415 15.0967 0.498647 14.8543C1.04523 13.8523 2.01371 12.4126 3.25129 11.1188C1.94153 8.63223 2.53953 5.26546 5.11893 3.72686C6.88003 2.67636 8.2969 2.77931 9.52388 2.86847C9.69587 2.88096 9.86414 2.89319 10.0291 2.90193C10.6974 2.93734 11.3129 2.92032 11.9703 2.68967C12.6272 2.45919 13.3678 1.99999 14.2457 1.08421C14.4648 0.855623 14.776 0.802403 15.0331 0.886954Z" />
</svg>
);

View file

@ -9485,7 +9485,6 @@
"xpack.enterpriseSearch.appSearch.engine.curations.empty.buttonLabel": "キュレーションガイドを読む",
"xpack.enterpriseSearch.appSearch.engine.curations.empty.description": "キュレーションを使用して、ドキュメントを昇格させるか非表示にします。最も検出させたい内容をユーザーに検出させるように支援します。",
"xpack.enterpriseSearch.appSearch.engine.curations.empty.noCurationsTitle": "最初のキュレーションを作成",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.description": "非表示のドキュメントはオーガニック結果に表示されません。",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyDescription": "上記のオーガニック結果の目アイコンをクリックしてドキュメントを非表示にするか、結果を手動で検索して非表示にします。",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyTitle": "まだドキュメントを非表示にしていません",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.removeAllButtonLabel": "すべて復元",

View file

@ -9580,7 +9580,6 @@
"xpack.enterpriseSearch.appSearch.engine.curations.empty.buttonLabel": "阅读策展指南",
"xpack.enterpriseSearch.appSearch.engine.curations.empty.description": "使用策展提升和隐藏文档。帮助人们发现最想让他们发现的内容。",
"xpack.enterpriseSearch.appSearch.engine.curations.empty.noCurationsTitle": "创建您的首个策展",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.description": "隐藏的文档将不显示在有机结果中。",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyDescription": "通过单击上面有机结果上的眼睛图标,可隐藏文档,或手动搜索和隐藏结果。",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyTitle": "您尚未隐藏任何文档",
"xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.removeAllButtonLabel": "全部还原",