[Curation] Add promoted/hidden documents section & logic + Restore defaults button (#94769)

* Set up promoted & hidden documents logic

* Set up result utility for converting CurationResult to Result

* Set up AddResultButton in documents sections

- not hooked up to anything right now, but will be in the next PR

* Add HiddenDocuments section

* Add PromotedDocuments section w/ draggable results

* Update OrganicDocuments results with promote/hide actions

* Add the Restore Defaults button+logic

* PR feedback: key ID

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Constance 2021-03-17 20:01:50 -07:00 committed by GitHub
parent ad18739de7
commit f4da06349d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 970 additions and 17 deletions

View file

@ -32,8 +32,43 @@ export const SUCCESS_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.deleteSuccessMessage',
{ defaultMessage: 'Successfully removed curation.' }
);
export const RESTORE_CONFIRMATION = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.restoreConfirmation',
{
defaultMessage:
'Are you sure you want to clear your changes and return to your default results?',
}
);
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.' }
);
export const PROMOTE_DOCUMENT_ACTION = {
title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.promoteButtonLabel', {
defaultMessage: 'Promote this result',
}),
iconType: 'starPlusEmpty',
iconColor: 'primary',
};
export const DEMOTE_DOCUMENT_ACTION = {
title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.demoteButtonLabel', {
defaultMessage: 'Demote this result',
}),
iconType: 'starMinusFilled',
iconColor: 'primary',
};
export const HIDE_DOCUMENT_ACTION = {
title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.hideButtonLabel', {
defaultMessage: 'Hide this result',
}),
iconType: 'eyeClosed',
iconColor: 'danger',
};
export const SHOW_DOCUMENT_ACTION = {
title: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.showButtonLabel', {
defaultMessage: 'Show this result',
}),
iconType: 'eye',
iconColor: 'primary',
};

View file

@ -12,7 +12,7 @@ import { setMockActions, setMockValues, rerender } from '../../../../__mocks__';
import React from 'react';
import { useParams } from 'react-router-dom';
import { shallow } from 'enzyme';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiPageHeader } from '@elastic/eui';
@ -34,6 +34,7 @@ describe('Curation', () => {
};
const actions = {
loadCuration: jest.fn(),
resetCuration: jest.fn(),
};
beforeEach(() => {
@ -75,4 +76,33 @@ describe('Curation', () => {
rerender(wrapper);
expect(actions.loadCuration).toHaveBeenCalledTimes(2);
});
describe('restore defaults button', () => {
let restoreDefaultsButton: ShallowWrapper;
let confirmSpy: jest.SpyInstance;
beforeAll(() => {
const wrapper = shallow(<Curation {...props} />);
const headerActions = wrapper.find(EuiPageHeader).prop('rightSideItems');
restoreDefaultsButton = shallow(headerActions![0] as React.ReactElement);
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

@ -10,17 +10,18 @@ import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { EuiPageHeader, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiPageHeader, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FlashMessages } from '../../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs';
import { Loading } from '../../../../shared/loading';
import { MANAGE_CURATION_TITLE } from '../constants';
import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants';
import { CurationLogic } from './curation_logic';
import { OrganicDocuments } from './documents';
import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents';
import { ActiveQuerySelect, ManageQueriesModal } from './queries';
interface Props {
@ -29,7 +30,7 @@ interface Props {
export const Curation: React.FC<Props> = ({ curationsBreadcrumb }) => {
const { curationId } = useParams() as { curationId: string };
const { loadCuration } = useActions(CurationLogic({ curationId }));
const { loadCuration, resetCuration } = useActions(CurationLogic({ curationId }));
const { dataLoading, queries } = useValues(CurationLogic({ curationId }));
useEffect(() => {
@ -43,7 +44,18 @@ export const Curation: React.FC<Props> = ({ curationsBreadcrumb }) => {
<SetPageChrome trail={[...curationsBreadcrumb, queries.join(', ')]} />
<EuiPageHeader
pageTitle={MANAGE_CURATION_TITLE}
/* TODO: Restore defaults button */
rightSideItems={[
<EuiButton
color="danger"
onClick={() => {
if (window.confirm(RESTORE_CONFIRMATION)) resetCuration();
}}
>
{i18n.translate('xpack.enterpriseSearch.appSearch.actions.restoreDefaults', {
defaultMessage: 'Restore defaults',
})}
</EuiButton>,
]}
responsive={false}
/>
@ -59,9 +71,11 @@ export const Curation: React.FC<Props> = ({ curationsBreadcrumb }) => {
<EuiSpacer size="xl" />
<FlashMessages />
{/* TODO: PromotedDocuments section */}
<PromotedDocuments />
<EuiSpacer />
<OrganicDocuments />
{/* TODO: HiddenDocuments section */}
<EuiSpacer />
<HiddenDocuments />
{/* TODO: AddResult flyout */}
</>

View file

@ -51,6 +51,10 @@ describe('CurationLogic', () => {
queriesLoading: false,
activeQuery: '',
organicDocumentsLoading: false,
promotedIds: [],
promotedDocumentsLoading: false,
hiddenIds: [],
hiddenDocumentsLoading: false,
};
beforeEach(() => {
@ -64,7 +68,7 @@ describe('CurationLogic', () => {
describe('actions', () => {
describe('onCurationLoad', () => {
it('should set curation, queries, activeQuery, & all loading states to false', () => {
it('should set curation, queries, activeQuery, promotedIds, hiddenIds, & all loading states to false', () => {
mount();
CurationLogic.actions.onCurationLoad(MOCK_CURATION_RESPONSE);
@ -74,9 +78,13 @@ describe('CurationLogic', () => {
curation: MOCK_CURATION_RESPONSE,
queries: ['some search'],
activeQuery: 'some search',
promotedIds: ['some-promoted-document'],
hiddenIds: ['some-hidden-document'],
dataLoading: false,
queriesLoading: false,
organicDocumentsLoading: false,
promotedDocumentsLoading: false,
hiddenDocumentsLoading: false,
});
});
@ -95,6 +103,8 @@ describe('CurationLogic', () => {
dataLoading: true,
queriesLoading: true,
organicDocumentsLoading: true,
promotedDocumentsLoading: true,
hiddenDocumentsLoading: true,
});
CurationLogic.actions.onCurationError();
@ -104,6 +114,8 @@ describe('CurationLogic', () => {
dataLoading: false,
queriesLoading: false,
organicDocumentsLoading: false,
promotedDocumentsLoading: false,
hiddenDocumentsLoading: false,
});
});
});
@ -136,6 +148,121 @@ describe('CurationLogic', () => {
});
});
});
describe('setPromotedIds', () => {
it('should set promotedIds state & promotedDocumentsLoading to true', () => {
mount();
CurationLogic.actions.setPromotedIds(['hello', 'world']);
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
promotedIds: ['hello', 'world'],
promotedDocumentsLoading: true,
});
});
});
describe('addPromotedId', () => {
it('should set promotedIds state & promotedDocumentsLoading to true', () => {
mount({ promotedIds: ['hello'] });
CurationLogic.actions.addPromotedId('world');
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
promotedIds: ['hello', 'world'],
promotedDocumentsLoading: true,
});
});
});
describe('removePromotedId', () => {
it('should set promotedIds state & promotedDocumentsLoading to true', () => {
mount({ promotedIds: ['hello', 'deleteme', 'world'] });
CurationLogic.actions.removePromotedId('deleteme');
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
promotedIds: ['hello', 'world'],
promotedDocumentsLoading: true,
});
});
});
describe('clearPromotedId', () => {
it('should reset promotedIds state & set promotedDocumentsLoading to true', () => {
mount({ promotedIds: ['hello', 'world'] });
CurationLogic.actions.clearPromotedIds();
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
promotedIds: [],
promotedDocumentsLoading: true,
});
});
});
describe('addHiddenId', () => {
it('should set hiddenIds state & hiddenDocumentsLoading to true', () => {
mount({ hiddenIds: ['hello'] });
CurationLogic.actions.addHiddenId('world');
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
hiddenIds: ['hello', 'world'],
hiddenDocumentsLoading: true,
});
});
});
describe('removeHiddenId', () => {
it('should set hiddenIds state & hiddenDocumentsLoading to true', () => {
mount({ hiddenIds: ['hello', 'deleteme', 'world'] });
CurationLogic.actions.removeHiddenId('deleteme');
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
hiddenIds: ['hello', 'world'],
hiddenDocumentsLoading: true,
});
});
});
describe('clearHiddenId', () => {
it('should reset hiddenIds state & set hiddenDocumentsLoading to true', () => {
mount({ hiddenIds: ['hello', 'world'] });
CurationLogic.actions.clearHiddenIds();
expect(CurationLogic.values).toEqual({
...DEFAULT_VALUES,
hiddenIds: [],
hiddenDocumentsLoading: true,
});
});
});
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('listeners', () => {
@ -187,6 +314,8 @@ describe('CurationLogic', () => {
{
queries: ['a', 'b', 'c'],
activeQuery: 'b',
promotedIds: ['d', 'e', 'f'],
hiddenIds: ['g'],
},
{ curationId: 'cur-123456789' }
);
@ -199,7 +328,7 @@ describe('CurationLogic', () => {
expect(http.put).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/curations/cur-123456789',
{
body: '{"queries":["a","b","c"],"query":"b","promoted":[],"hidden":[]}', // Uses state currently in CurationLogic
body: '{"queries":["a","b","c"],"query":"b","promoted":["d","e","f"],"hidden":["g"]}', // Uses state currently in CurationLogic
}
);
expect(CurationLogic.actions.onCurationLoad).toHaveBeenCalledWith(MOCK_CURATION_RESPONSE);
@ -249,6 +378,34 @@ describe('CurationLogic', () => {
it('setActiveQuery', () => {
CurationLogic.actions.setActiveQuery('test');
});
it('setPromotedIds', () => {
CurationLogic.actions.setPromotedIds(['test']);
});
it('addPromotedId', () => {
CurationLogic.actions.addPromotedId('test');
});
it('removePromotedId', () => {
CurationLogic.actions.removePromotedId('test');
});
it('clearPromotedIds', () => {
CurationLogic.actions.clearPromotedIds();
});
it('addHiddenId', () => {
CurationLogic.actions.addHiddenId('test');
});
it('removeHiddenId', () => {
CurationLogic.actions.removeHiddenId('test');
});
it('clearHiddenIds', () => {
CurationLogic.actions.clearHiddenIds();
});
});
});
});

View file

@ -14,6 +14,7 @@ import { ENGINE_CURATIONS_PATH } from '../../../routes';
import { EngineLogic, generateEnginePath } from '../../engine';
import { Curation } from '../types';
import { addDocument, removeDocument } from '../utils';
interface CurationValues {
dataLoading: boolean;
@ -22,6 +23,10 @@ interface CurationValues {
queriesLoading: boolean;
activeQuery: string;
organicDocumentsLoading: boolean;
promotedIds: string[];
promotedDocumentsLoading: boolean;
hiddenIds: string[];
hiddenDocumentsLoading: boolean;
}
interface CurationActions {
@ -31,6 +36,14 @@ interface CurationActions {
onCurationError(): void;
updateQueries(queries: Curation['queries']): { queries: Curation['queries'] };
setActiveQuery(query: string): { query: string };
setPromotedIds(promotedIds: string[]): { promotedIds: string[] };
addPromotedId(id: string): { id: string };
removePromotedId(id: string): { id: string };
clearPromotedIds(): void;
addHiddenId(id: string): { id: string };
removeHiddenId(id: string): { id: string };
clearHiddenIds(): void;
resetCuration(): void;
}
interface CurationProps {
@ -46,12 +59,21 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
onCurationError: true,
updateQueries: (queries) => ({ queries }),
setActiveQuery: (query) => ({ query }),
setPromotedIds: (promotedIds) => ({ promotedIds }),
addPromotedId: (id) => ({ id }),
removePromotedId: (id) => ({ id }),
clearPromotedIds: true,
addHiddenId: (id) => ({ id }),
removeHiddenId: (id) => ({ id }),
clearHiddenIds: true,
resetCuration: true,
}),
reducers: () => ({
dataLoading: [
true,
{
loadCuration: () => true,
resetCuration: () => true,
onCurationLoad: () => false,
onCurationError: () => false,
},
@ -99,6 +121,46 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
onCurationError: () => false,
},
],
promotedIds: [
[],
{
onCurationLoad: (_, { curation }) => curation.promoted.map((document) => document.id),
setPromotedIds: (_, { promotedIds }) => promotedIds,
addPromotedId: (promotedIds, { id }) => addDocument(promotedIds, id),
removePromotedId: (promotedIds, { id }) => removeDocument(promotedIds, id),
clearPromotedIds: () => [],
},
],
promotedDocumentsLoading: [
false,
{
setPromotedIds: () => true,
addPromotedId: () => true,
removePromotedId: () => true,
clearPromotedIds: () => true,
onCurationLoad: () => false,
onCurationError: () => false,
},
],
hiddenIds: [
[],
{
onCurationLoad: (_, { curation }) => curation.hidden.map((document) => document.id),
addHiddenId: (hiddenIds, { id }) => addDocument(hiddenIds, id),
removeHiddenId: (hiddenIds, { id }) => removeDocument(hiddenIds, id),
clearHiddenIds: () => [],
},
],
hiddenDocumentsLoading: [
false,
{
addHiddenId: () => true,
removeHiddenId: () => true,
clearHiddenIds: () => true,
onCurationLoad: () => false,
onCurationError: () => false,
},
],
}),
listeners: ({ actions, values, props }) => ({
loadCuration: async () => {
@ -131,8 +193,8 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
body: JSON.stringify({
queries: values.queries,
query: values.activeQuery,
promoted: [], // TODO: promotedIds state
hidden: [], // TODO: hiddenIds state
promoted: values.promotedIds,
hidden: values.hiddenIds,
}),
}
);
@ -149,5 +211,16 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions,
actions.updateCuration();
},
setActiveQuery: () => actions.updateCuration(),
setPromotedIds: () => actions.updateCuration(),
addPromotedId: () => actions.updateCuration(),
removePromotedId: () => actions.updateCuration(),
clearPromotedIds: () => actions.updateCuration(),
addHiddenId: () => actions.updateCuration(),
removeHiddenId: () => actions.updateCuration(),
clearHiddenIds: () => actions.updateCuration(),
resetCuration: () => {
actions.clearPromotedIds();
actions.clearHiddenIds();
},
}),
});

View file

@ -0,0 +1,83 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui';
import { DataPanel } from '../../../data_panel';
import { CurationResult } from '../results';
import { HiddenDocuments } from './';
describe('HiddenDocuments', () => {
const values = {
curation: {
hidden: [
{ id: 'mock-document-1' },
{ id: 'mock-document-2' },
{ id: 'mock-document-3' },
{ id: 'mock-document-4' },
{ id: 'mock-document-5' },
],
},
hiddenDocumentsLoading: false,
};
const actions = {
removeHiddenId: jest.fn(),
clearHiddenIds: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders a list of hidden documents', () => {
const wrapper = shallow(<HiddenDocuments />);
expect(wrapper.find(CurationResult)).toHaveLength(5);
});
it('renders an empty state & hides the panel actions when empty', () => {
setMockValues({ ...values, curation: { hidden: [] } });
const wrapper = shallow(<HiddenDocuments />);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
expect(wrapper.find(DataPanel).prop('action')).toBe(false);
});
it('renders a loading state', () => {
setMockValues({ ...values, hiddenDocumentsLoading: true });
const wrapper = shallow(<HiddenDocuments />);
expect(wrapper.find(DataPanel).prop('isLoading')).toEqual(true);
});
describe('actions', () => {
it('renders results with an action button that un-hides the result', () => {
const wrapper = shallow(<HiddenDocuments />);
const result = wrapper.find(CurationResult).last();
result.prop('actions')[0].onClick();
expect(actions.removeHiddenId).toHaveBeenCalledWith('mock-document-5');
});
it('renders a restore all button that un-hides all hidden results', () => {
const wrapper = shallow(<HiddenDocuments />);
const panelActions = shallow(wrapper.find(DataPanel).prop('action') as React.ReactElement);
panelActions.find(EuiButtonEmpty).simulate('click');
expect(actions.clearHiddenIds).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataPanel } from '../../../data_panel';
import { SHOW_DOCUMENT_ACTION } from '../../constants';
import { CurationLogic } from '../curation_logic';
import { AddResultButton, CurationResult, convertToResultFormat } from '../results';
export const HiddenDocuments: React.FC = () => {
const { clearHiddenIds, removeHiddenId } = useActions(CurationLogic);
const { curation, hiddenDocumentsLoading } = useValues(CurationLogic);
const documents = curation.hidden;
const hasDocuments = documents.length > 0;
return (
<DataPanel
filled
iconType="eyeClosed"
title={
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.title',
{ defaultMessage: 'Hidden documents' }
)}
</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">
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty onClick={clearHiddenIds} iconType="menuUp" size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.removeAllButtonLabel',
{ defaultMessage: 'Restore all' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
)
}
isLoading={hiddenDocumentsLoading}
>
{hasDocuments ? (
documents.map((document) => (
<CurationResult
key={document.id}
result={convertToResultFormat(document)}
actions={[
{
...SHOW_DOCUMENT_ACTION,
onClick: () => removeHiddenId(document.id),
},
]}
/>
))
) : (
<EuiEmptyPrompt
titleSize="s"
title={
<h3>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyTitle',
{ defaultMessage: 'No documents are being hidden for this query' }
)}
</h3>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyDescription',
{
defaultMessage:
'Hide documents by clicking the eye icon on the organic results above, or search and hide a result manually.',
}
)}
actions={<AddResultButton />}
/>
)}
</DataPanel>
);
};

View file

@ -5,4 +5,6 @@
* 2.0.
*/
export { PromotedDocuments } from './promoted_documents';
export { OrganicDocuments } from './organic_documents';
export { HiddenDocuments } from './hidden_documents';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { setMockValues } from '../../../../../__mocks__';
import { setMockValues, setMockActions } from '../../../../../__mocks__';
import React from 'react';
@ -31,10 +31,15 @@ describe('OrganicDocuments', () => {
activeQuery: 'world',
organicDocumentsLoading: false,
};
const actions = {
addPromotedId: jest.fn(),
addHiddenId: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders a list of organic results', () => {
@ -64,4 +69,22 @@ describe('OrganicDocuments', () => {
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
describe('actions', () => {
it('renders results with an action button that promotes the result', () => {
const wrapper = shallow(<OrganicDocuments />);
const result = wrapper.find(CurationResult).first();
result.prop('actions')[1].onClick();
expect(actions.addPromotedId).toHaveBeenCalledWith('mock-document-1');
});
it('renders results with an action button that hides the result', () => {
const wrapper = shallow(<OrganicDocuments />);
const result = wrapper.find(CurationResult).last();
result.prop('actions')[0].onClick();
expect(actions.addHiddenId).toHaveBeenCalledWith('mock-document-3');
});
});
});

View file

@ -7,7 +7,7 @@
import React from 'react';
import { useValues } from 'kea';
import { useValues, useActions } from 'kea';
import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -15,11 +15,16 @@ import { i18n } from '@kbn/i18n';
import { DataPanel } from '../../../data_panel';
import { Result } from '../../../result/types';
import { RESULT_ACTIONS_DIRECTIONS } from '../../constants';
import {
RESULT_ACTIONS_DIRECTIONS,
PROMOTE_DOCUMENT_ACTION,
HIDE_DOCUMENT_ACTION,
} from '../../constants';
import { CurationLogic } from '../curation_logic';
import { CurationResult } from '../results';
export const OrganicDocuments: React.FC = () => {
const { addPromotedId, addHiddenId } = useActions(CurationLogic);
const { curation, activeQuery, organicDocumentsLoading } = useValues(CurationLogic);
const documents = curation.organic;
@ -48,7 +53,16 @@ export const OrganicDocuments: React.FC = () => {
<CurationResult
result={document}
key={document.id.raw}
actions={[]} // TODO: Next Curation PR
actions={[
{
...HIDE_DOCUMENT_ACTION,
onClick: () => addHiddenId(document.id.raw),
},
{
...PROMOTE_DOCUMENT_ACTION,
onClick: () => addPromotedId(document.id.raw),
},
]}
/>
))
) : organicDocumentsLoading ? (

View file

@ -0,0 +1,116 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiDragDropContext, EuiDraggable, EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui';
import { DataPanel } from '../../../data_panel';
import { CurationResult } from '../results';
import { PromotedDocuments } from './';
describe('PromotedDocuments', () => {
const values = {
curation: {
promoted: [
{ id: 'mock-document-1' },
{ id: 'mock-document-2' },
{ id: 'mock-document-3' },
{ id: 'mock-document-4' },
],
},
promotedIds: ['mock-document-1', 'mock-document-2', 'mock-document-3', 'mock-document-4'],
promotedDocumentsLoading: false,
};
const actions = {
setPromotedIds: jest.fn(),
clearPromotedIds: jest.fn(),
removePromotedId: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
const getDraggableChildren = (draggableWrapper: any) => {
return draggableWrapper.renderProp('children')({}, {}, {});
};
it('renders a list of draggable promoted documents', () => {
const wrapper = shallow(<PromotedDocuments />);
expect(wrapper.find(EuiDraggable)).toHaveLength(4);
wrapper.find(EuiDraggable).forEach((draggableWrapper) => {
expect(getDraggableChildren(draggableWrapper).find(CurationResult).exists()).toBe(true);
});
});
it('renders an empty state & 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('renders a loading state', () => {
setMockValues({ ...values, promotedDocumentsLoading: true });
const wrapper = shallow(<PromotedDocuments />);
expect(wrapper.find(DataPanel).prop('isLoading')).toEqual(true);
});
describe('actions', () => {
it('renders results with an action button that demotes the result', () => {
const wrapper = shallow(<PromotedDocuments />);
const result = getDraggableChildren(wrapper.find(EuiDraggable).last());
result.prop('actions')[0].onClick();
expect(actions.removePromotedId).toHaveBeenCalledWith('mock-document-4');
});
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);
panelActions.find(EuiButtonEmpty).simulate('click');
expect(actions.clearPromotedIds).toHaveBeenCalled();
});
describe('draggging', () => {
it('calls setPromotedIds with the reordered list when users are done dragging', () => {
const wrapper = shallow(<PromotedDocuments />);
wrapper.find(EuiDragDropContext).simulate('dragEnd', {
source: { index: 3 },
destination: { index: 0 },
});
expect(actions.setPromotedIds).toHaveBeenCalledWith([
'mock-document-4',
'mock-document-1',
'mock-document-2',
'mock-document-3',
]);
});
it('does not error if source/destination are unavailable on drag end', () => {
const wrapper = shallow(<PromotedDocuments />);
wrapper.find(EuiDragDropContext).simulate('dragEnd', {});
expect(actions.setPromotedIds).not.toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import {
EuiFlexGroup,
EuiFlexItem,
EuiEmptyPrompt,
EuiButtonEmpty,
EuiDragDropContext,
DropResult,
EuiDroppable,
EuiDraggable,
euiDragDropReorder,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataPanel } from '../../../data_panel';
import { DEMOTE_DOCUMENT_ACTION } from '../../constants';
import { CurationLogic } from '../curation_logic';
import { AddResultButton, CurationResult, convertToResultFormat } from '../results';
export const PromotedDocuments: React.FC = () => {
const { curation, promotedIds, promotedDocumentsLoading } = useValues(CurationLogic);
const documents = curation.promoted;
const hasDocuments = documents.length > 0;
const { setPromotedIds, clearPromotedIds, removePromotedId } = useActions(CurationLogic);
const reorderPromotedIds = ({ source, destination }: DropResult) => {
if (source && destination) {
const reorderedIds = euiDragDropReorder(promotedIds, source.index, destination.index);
setPromotedIds(reorderedIds);
}
};
return (
<DataPanel
filled
iconType="starFilled"
title={
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title',
{ defaultMessage: 'Promoted documents' }
)}
</h2>
}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description',
{
defaultMessage:
'Promoted results appear before organic results. Documents can be re-ordered.',
}
)}
action={
hasDocuments && (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<AddResultButton />
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty onClick={clearPromotedIds} iconType="menuDown" size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel',
{ defaultMessage: 'Demote all' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
)
}
isLoading={promotedDocumentsLoading}
>
{hasDocuments ? (
<EuiDragDropContext onDragEnd={reorderPromotedIds}>
<EuiDroppable droppableId="PromotedDocuments" spacing="m">
{documents.map((document, i: number) => (
<EuiDraggable
index={i}
key={document.id}
draggableId={document.id}
customDragHandle
spacing="none"
>
{(provided) => (
<CurationResult
key={document.id}
result={convertToResultFormat(document)}
actions={[
{
...DEMOTE_DOCUMENT_ACTION,
onClick: () => removePromotedId(document.id),
},
]}
dragHandleProps={provided.dragHandleProps}
/>
)}
</EuiDraggable>
))}
</EuiDroppable>
</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.',
}
)}
actions={<AddResultButton />}
/>
)}
</DataPanel>
);
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { AddResultButton } from './';
describe('AddResultButton', () => {
let wrapper: ShallowWrapper;
beforeAll(() => {
wrapper = shallow(<AddResultButton />);
});
it('renders', () => {
expect(wrapper.find(EuiButton)).toHaveLength(1);
});
it('opens the add result flyout on click', () => {
wrapper.find(EuiButton).simulate('click');
// TODO: assert on logic action
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const AddResultButton: React.FC = () => {
return (
<EuiButton onClick={() => {} /* TODO */} iconType="plusInCircle" size="s" fill>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.buttonLabel', {
defaultMessage: 'Add result manually',
})}
</EuiButton>
);
};

View file

@ -5,4 +5,6 @@
* 2.0.
*/
export { AddResultButton } from './add_result_button';
export { CurationResult } from './curation_result';
export { convertToResultFormat } from './utils';

View file

@ -0,0 +1,46 @@
/*
* 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 { convertToResultFormat, convertIdToMeta } from './utils';
describe('convertToResultFormat', () => {
it('converts curation results to a format that the Result component can use', () => {
expect(
convertToResultFormat({
id: 'some-id',
someField: 'some flat string',
anotherField: '123456',
})
).toEqual({
_meta: {
id: 'some-id',
},
id: {
raw: 'some-id',
snippet: null,
},
someField: {
raw: 'some flat string',
snippet: null,
},
anotherField: {
raw: '123456',
snippet: null,
},
});
});
});
describe('convertIdToMeta', () => {
it('creates an approximate _meta object based on the curation result ID', () => {
expect(convertIdToMeta('some-id')).toEqual({ id: 'some-id' });
expect(convertIdToMeta('some-engine|some-id')).toEqual({
id: 'some-id',
engine: 'some-engine',
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { Result } from '../../../result/types';
import { CurationResult } from '../../types';
/**
* The `promoted` and `hidden` keys from the internal curations endpoints
* currently return a document data structure that our Result component can't
* correctly parse - we need to attempt to naively transform the data in order
* to display it in a Result
*
* TODO: Ideally someday we can update our internal curations endpoint to return
* the same Result-ready data structure that the `organic` endpoint uses, and
* remove this file when that happens
*/
export const convertToResultFormat = (document: CurationResult): Result => {
const result = {} as Result;
// Convert `key: 'value'` into `key: { raw: 'value' }`
Object.entries(document).forEach(([key, value]) => {
result[key] = {
raw: value,
snippet: null, // Don't try to provide a snippet, we can't really guesstimate it
};
});
// Add the _meta obj needed by Result
result._meta = convertIdToMeta(document.id);
return result;
};
export const convertIdToMeta = (id: string): Result['_meta'] => {
const splitId = id.split('|');
const isMetaEngine = splitId.length > 1;
return isMetaEngine
? {
engine: splitId[0],
id: splitId[1],
}
: ({ id } as Result['_meta']);
// Note: We're casting this as _meta even though `engine` is missing,
// since for source engines the engine shouldn't matter / be displayed,
// but if needed we could likely populate this from EngineLogic.values
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { convertToDate } from './utils';
import { convertToDate, addDocument, removeDocument } from './utils';
describe('convertToDate', () => {
it('converts the English-only server timestamps to a parseable Date', () => {
@ -16,3 +16,23 @@ describe('convertToDate', () => {
expect(date.getFullYear()).toEqual(1970);
});
});
describe('addDocument', () => {
it('adds a new document to the end of the document array without mutating the original array', () => {
const originalDocuments = ['hello'];
const newDocuments = addDocument(originalDocuments, 'world');
expect(newDocuments).toEqual(['hello', 'world']);
expect(newDocuments).not.toBe(originalDocuments); // Would fail if we had mutated the array
});
});
describe('removeDocument', () => {
it('removes a specific document from the array without mutating the original array', () => {
const originalDocuments = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'];
const newDocuments = removeDocument(originalDocuments, 'dolor');
expect(newDocuments).toEqual(['lorem', 'ipsum', 'sit', 'amet']);
expect(newDocuments).not.toBe(originalDocuments); // Would fail if we had mutated the array
});
});

View file

@ -14,3 +14,14 @@ export const convertToDate = (serverDateString: string): Date => {
.replace('AM', ' AM');
return new Date(readableDateString);
};
export const addDocument = (documentArray: string[], newDocument: string) => {
return [...documentArray, newDocument];
};
export const removeDocument = (documentArray: string[], deletedDocument: string) => {
const newArray = [...documentArray];
const indexToDelete = newArray.indexOf(deletedDocument);
newArray.splice(indexToDelete, 1);
return newArray;
};