[App Search] Wired up action buttons for suggestion detail view (#114183)

This commit is contained in:
Jason Stoltzfus 2021-10-07 17:38:05 -04:00 committed by GitHub
parent 9bb8f2246c
commit a5a8bb29d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 464 additions and 82 deletions

View file

@ -45,6 +45,7 @@ const MOCK_RESPONSE: SuggestionsAPIResponse = {
updated_at: '2021-07-08T14:35:50Z',
promoted: ['1', '2'],
status: 'applied',
operation: 'create',
},
],
};

View file

@ -14,6 +14,7 @@ export interface CurationSuggestion {
promoted: string[];
status: 'pending' | 'applied' | 'automated' | 'rejected' | 'disabled';
curation_id?: string;
operation: 'create' | 'update' | 'delete';
override_curation_id?: string;
}

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { setMockActions } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
@ -12,18 +14,22 @@ import { shallow } from 'enzyme';
import { CurationActionBar } from './curation_action_bar';
describe('CurationActionBar', () => {
const handleAcceptClick = jest.fn();
const handleRejectClick = jest.fn();
const actions = {
acceptSuggestion: jest.fn(),
rejectSuggestion: jest.fn(),
};
beforeAll(() => {
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(
<CurationActionBar onAcceptClick={handleAcceptClick} onRejectClick={handleRejectClick} />
);
const wrapper = shallow(<CurationActionBar />);
wrapper.find('[data-test-subj="rejectButton"]').simulate('click');
expect(handleRejectClick).toHaveBeenCalled();
expect(actions.rejectSuggestion).toHaveBeenCalled();
wrapper.find('[data-test-subj="acceptButton"]').simulate('click');
expect(handleAcceptClick).toHaveBeenCalled();
expect(actions.acceptSuggestion).toHaveBeenCalled();
});
});

View file

@ -7,17 +7,17 @@
import React from 'react';
import { useActions } from 'kea';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CurationActionsPopover } from './curation_actions_popover';
import { CurationSuggestionLogic } from './curation_suggestion_logic';
interface Props {
onAcceptClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onRejectClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const CurationActionBar: React.FC = () => {
const { acceptSuggestion, rejectSuggestion } = useActions(CurationSuggestionLogic);
export const CurationActionBar: React.FC<Props> = ({ onAcceptClick, onRejectClick }) => {
return (
<EuiFlexGroup>
<EuiFlexItem>
@ -41,7 +41,7 @@ export const CurationActionBar: React.FC<Props> = ({ onAcceptClick, onRejectClic
color="danger"
iconType="crossInACircleFilled"
data-test-subj="rejectButton"
onClick={onRejectClick}
onClick={rejectSuggestion}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.rejectButtonLabel',
@ -55,7 +55,7 @@ export const CurationActionBar: React.FC<Props> = ({ onAcceptClick, onRejectClic
color="success"
iconType="checkInCircleFilled"
data-test-subj="acceptButton"
onClick={onAcceptClick}
onClick={acceptSuggestion}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.acceptButtonLabel',
@ -64,12 +64,7 @@ export const CurationActionBar: React.FC<Props> = ({ onAcceptClick, onRejectClic
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<CurationActionsPopover
onAccept={() => {}}
onAutomate={() => {}}
onReject={() => {}}
onTurnOff={() => {}}
/>
<CurationActionsPopover />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { setMockActions } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
@ -12,48 +14,40 @@ import { shallow } from 'enzyme';
import { CurationActionsPopover } from './curation_actions_popover';
describe('CurationActionsPopover', () => {
const handleAccept = jest.fn();
const handleAutomate = jest.fn();
const handleReject = jest.fn();
const handleTurnOff = jest.fn();
const actions = {
acceptSuggestion: jest.fn(),
acceptAndAutomateSuggestion: jest.fn(),
rejectSuggestion: jest.fn(),
rejectAndDisableSuggestion: jest.fn(),
};
beforeAll(() => {
setMockActions(actions);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', () => {
const wrapper = shallow(
<CurationActionsPopover
onAccept={handleAccept}
onAutomate={handleAutomate}
onReject={handleReject}
onTurnOff={handleTurnOff}
/>
);
const wrapper = shallow(<CurationActionsPopover />);
expect(wrapper.isEmptyRender()).toBe(false);
wrapper.find('[data-test-subj="acceptButton"]').simulate('click');
expect(handleAccept).toHaveBeenCalled();
expect(actions.acceptSuggestion).toHaveBeenCalled();
wrapper.find('[data-test-subj="automateButton"]').simulate('click');
expect(handleAutomate).toHaveBeenCalled();
expect(actions.acceptAndAutomateSuggestion).toHaveBeenCalled();
wrapper.find('[data-test-subj="rejectButton"]').simulate('click');
expect(handleReject).toHaveBeenCalled();
expect(actions.rejectSuggestion).toHaveBeenCalled();
wrapper.find('[data-test-subj="turnoffButton"]').simulate('click');
expect(handleTurnOff).toHaveBeenCalled();
expect(actions.rejectAndDisableSuggestion).toHaveBeenCalled();
});
it('can open and close', () => {
const wrapper = shallow(
<CurationActionsPopover
onAccept={handleAccept}
onAutomate={handleAutomate}
onReject={handleReject}
onTurnOff={handleTurnOff}
/>
);
const wrapper = shallow(<CurationActionsPopover />);
expect(wrapper.prop('isOpen')).toBe(false);

View file

@ -7,6 +7,8 @@
import React, { useState } from 'react';
import { useActions } from 'kea';
import {
EuiButtonIcon,
EuiListGroup,
@ -16,20 +18,16 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
onAccept: () => void;
onAutomate: () => void;
onReject: () => void;
onTurnOff: () => void;
}
import { CurationSuggestionLogic } from './curation_suggestion_logic';
export const CurationActionsPopover: React.FC<Props> = ({
onAccept,
onAutomate,
onReject,
onTurnOff,
}) => {
export const CurationActionsPopover: React.FC = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const {
acceptSuggestion,
acceptAndAutomateSuggestion,
rejectSuggestion,
rejectAndDisableSuggestion,
} = useActions(CurationSuggestionLogic);
const onButtonClick = () => setIsPopoverOpen(!isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);
@ -63,7 +61,7 @@ export const CurationActionsPopover: React.FC<Props> = ({
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.actionsAcceptButtonLabel',
{ defaultMessage: 'Accept this suggestion' }
)}
onClick={onAccept}
onClick={acceptSuggestion}
data-test-subj="acceptButton"
/>
<EuiListGroupItem
@ -73,7 +71,7 @@ export const CurationActionsPopover: React.FC<Props> = ({
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.actionsAutomateButtonLabel',
{ defaultMessage: 'Automate - always accept new suggestions for this query' }
)}
onClick={onAutomate}
onClick={acceptAndAutomateSuggestion}
data-test-subj="automateButton"
/>
<EuiListGroupItem
@ -83,7 +81,7 @@ export const CurationActionsPopover: React.FC<Props> = ({
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.actionsRejectButtonLabel',
{ defaultMessage: 'Reject this suggestion' }
)}
onClick={onReject}
onClick={rejectSuggestion}
data-test-subj="rejectButton"
/>
<EuiListGroupItem
@ -93,7 +91,7 @@ export const CurationActionsPopover: React.FC<Props> = ({
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.actionsTurnOffButtonLabel',
{ defaultMessage: 'Reject and turn off suggestions for this query' }
)}
onClick={onTurnOff}
onClick={rejectAndDisableSuggestion}
data-test-subj="turnoffButton"
/>
</EuiListGroup>

View file

@ -65,10 +65,7 @@ export const CurationSuggestion: React.FC = () => {
pageTitle: suggestionQuery,
}}
>
<CurationActionBar
onAcceptClick={() => alert('Accepted')}
onRejectClick={() => alert('Rejected')}
/>
<CurationActionBar />
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>

View file

@ -9,6 +9,7 @@ import {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
mockKibanaValues,
} from '../../../../../__mocks__/kea_logic';
import { set } from 'lodash/fp';
@ -32,7 +33,8 @@ const suggestion: CurationSuggestion = {
query: 'foo',
updated_at: '2021-07-08T14:35:50Z',
promoted: ['1', '2', '3'],
status: 'applied',
status: 'pending',
operation: 'create',
};
const curation = {
@ -115,13 +117,51 @@ const MOCK_DOCUMENTS_RESPONSE = {
describe('CurationSuggestionLogic', () => {
const { mount } = new LogicMounter(CurationSuggestionLogic);
const { flashAPIErrors } = mockFlashMessageHelpers;
const { flashAPIErrors, setQueuedErrorMessage } = mockFlashMessageHelpers;
const { navigateToUrl } = mockKibanaValues;
const mountLogic = (props: object = {}) => {
mount(props, { query: 'foo-query' });
};
const { http } = mockHttpValues;
const itHandlesInlineErrors = (callback: () => void) => {
it('handles inline errors', async () => {
http.put.mockReturnValueOnce(
Promise.resolve({
results: [
{
error: 'error',
},
],
})
);
mountLogic({
suggestion,
});
callback();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
};
const itHandlesErrors = (httpMethod: any, callback: () => void) => {
it('handles errors', async () => {
httpMethod.mockReturnValueOnce(Promise.reject('error'));
mountLogic({
suggestion,
});
callback();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
};
beforeEach(() => {
jest.clearAllMocks();
});
@ -207,7 +247,8 @@ describe('CurationSuggestionLogic', () => {
query: 'foo',
updated_at: '2021-07-08T14:35:50Z',
promoted: ['1', '2', '3'],
status: 'applied',
status: 'pending',
operation: 'create',
},
// Note that these were re-ordered to match the 'promoted' list above, and since document
// 3 was not found it is not included in this list
@ -243,9 +284,7 @@ describe('CurationSuggestionLogic', () => {
);
http.post.mockReturnValueOnce(Promise.resolve(MOCK_DOCUMENTS_RESPONSE));
http.get.mockReturnValueOnce(Promise.resolve(curation));
mountLogic({
suggestion: set('curation_id', 'cur-6155e69c7a2f2e4f756303fd', suggestion),
});
mountLogic();
jest.spyOn(CurationSuggestionLogic.actions, 'onSuggestionLoaded');
CurationSuggestionLogic.actions.loadSuggestion();
@ -255,7 +294,6 @@ describe('CurationSuggestionLogic', () => {
'/internal/app_search/engines/some-engine/curations/cur-6155e69c7a2f2e4f756303fd',
{ query: { skip_record_analytics: 'true' } }
);
await nextTick();
expect(CurationSuggestionLogic.actions.onSuggestionLoaded).toHaveBeenCalledWith({
suggestion: expect.any(Object),
@ -264,14 +302,204 @@ describe('CurationSuggestionLogic', () => {
});
});
it('handles errors', async () => {
http.post.mockReturnValueOnce(Promise.reject('error'));
mount();
// This could happen if a user applies a suggestion and then navigates back to a detail page via
// the back button, etc. The suggestion still exists, it's just not in a "pending" state
// so we can show it.ga
it('will redirect if the suggestion is not found', async () => {
http.post.mockReturnValueOnce(Promise.resolve(set('results', [], MOCK_RESPONSE)));
mountLogic();
CurationSuggestionLogic.actions.loadSuggestion();
await nextTick();
expect(setQueuedErrorMessage).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations');
});
expect(flashAPIErrors).toHaveBeenCalledWith('error');
itHandlesErrors(http.post, () => {
CurationSuggestionLogic.actions.loadSuggestion();
});
});
describe('acceptSuggestion', () => {
it('will make an http call to apply the suggestion, and then navigate to that detail page', async () => {
http.put.mockReturnValueOnce(
Promise.resolve({
results: [
{
...suggestion,
status: 'accepted',
curation_id: 'cur-6155e69c7a2f2e4f756303fd',
},
],
})
);
mountLogic({
suggestion,
});
CurationSuggestionLogic.actions.acceptSuggestion();
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/search_relevance_suggestions',
{
body: JSON.stringify([
{
query: 'foo',
type: 'curation',
status: 'applied',
},
]),
}
);
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/curations/cur-6155e69c7a2f2e4f756303fd'
);
});
itHandlesErrors(http.put, () => {
CurationSuggestionLogic.actions.acceptSuggestion();
});
itHandlesInlineErrors(() => {
CurationSuggestionLogic.actions.acceptSuggestion();
});
});
describe('acceptAndAutomateSuggestion', () => {
it('will make an http call to apply the suggestion, and then navigate to that detail page', async () => {
http.put.mockReturnValueOnce(
Promise.resolve({
results: [
{
...suggestion,
status: 'accepted',
curation_id: 'cur-6155e69c7a2f2e4f756303fd',
},
],
})
);
mountLogic({
suggestion,
});
CurationSuggestionLogic.actions.acceptAndAutomateSuggestion();
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/search_relevance_suggestions',
{
body: JSON.stringify([
{
query: 'foo',
type: 'curation',
status: 'automated',
},
]),
}
);
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/curations/cur-6155e69c7a2f2e4f756303fd'
);
});
itHandlesErrors(http.put, () => {
CurationSuggestionLogic.actions.acceptAndAutomateSuggestion();
});
itHandlesInlineErrors(() => {
CurationSuggestionLogic.actions.acceptAndAutomateSuggestion();
});
});
describe('rejectSuggestion', () => {
it('will make an http call to apply the suggestion, and then navigate back the curations page', async () => {
http.put.mockReturnValueOnce(
Promise.resolve({
results: [
{
...suggestion,
status: 'rejected',
curation_id: 'cur-6155e69c7a2f2e4f756303fd',
},
],
})
);
mountLogic({
suggestion,
});
CurationSuggestionLogic.actions.rejectSuggestion();
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/search_relevance_suggestions',
{
body: JSON.stringify([
{
query: 'foo',
type: 'curation',
status: 'rejected',
},
]),
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations');
});
itHandlesErrors(http.put, () => {
CurationSuggestionLogic.actions.rejectSuggestion();
});
itHandlesInlineErrors(() => {
CurationSuggestionLogic.actions.rejectSuggestion();
});
});
describe('rejectAndDisableSuggestion', () => {
it('will make an http call to apply the suggestion, and then navigate back the curations page', async () => {
http.put.mockReturnValueOnce(
Promise.resolve({
results: [
{
...suggestion,
status: 'disabled',
curation_id: 'cur-6155e69c7a2f2e4f756303fd',
},
],
})
);
mountLogic({
suggestion,
});
CurationSuggestionLogic.actions.rejectAndDisableSuggestion();
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/search_relevance_suggestions',
{
body: JSON.stringify([
{
query: 'foo',
type: 'curation',
status: 'disabled',
},
]),
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations');
});
itHandlesErrors(http.put, () => {
CurationSuggestionLogic.actions.rejectAndDisableSuggestion();
});
itHandlesInlineErrors(() => {
CurationSuggestionLogic.actions.rejectAndDisableSuggestion();
});
});
});

View file

@ -8,12 +8,23 @@
import { kea, MakeLogicType } from 'kea';
import { HttpSetup } from 'kibana/public';
import { flashAPIErrors } from '../../../../../shared/flash_messages';
import { i18n } from '@kbn/i18n';
import {
flashAPIErrors,
setQueuedErrorMessage,
setQueuedSuccessMessage,
} from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { EngineLogic } from '../../../engine';
import { KibanaLogic } from '../../../../../shared/kibana';
import { ENGINE_CURATIONS_PATH, ENGINE_CURATION_PATH } from '../../../../routes';
import { EngineLogic, generateEnginePath } from '../../../engine';
import { Result } from '../../../result/types';
import { Curation, CurationSuggestion } from '../../types';
interface Error {
error: string;
}
interface CurationSuggestionValues {
dataLoading: boolean;
suggestion: CurationSuggestion | null;
@ -36,6 +47,10 @@ interface CurationSuggestionActions {
suggestedPromotedDocuments: Result[];
curation: Curation;
};
acceptSuggestion(): void;
acceptAndAutomateSuggestion(): void;
rejectSuggestion(): void;
rejectAndDisableSuggestion(): void;
}
interface CurationSuggestionProps {
@ -53,6 +68,10 @@ export const CurationSuggestionLogic = kea<
suggestedPromotedDocuments,
curation,
}),
acceptSuggestion: true,
acceptAndAutomateSuggestion: true,
rejectSuggestion: true,
rejectAndDisableSuggestion: true,
}),
reducers: () => ({
dataLoading: [
@ -81,13 +100,14 @@ export const CurationSuggestionLogic = kea<
},
],
}),
listeners: ({ actions, props }) => ({
listeners: ({ actions, values, props }) => ({
loadSuggestion: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const suggestion = await getSuggestions(http, engineName, props.query);
const suggestion = await getSuggestion(http, engineName, props.query);
if (!suggestion) return;
const promotedIds: string[] = suggestion.promoted;
const documentDetailsResopnse = getDocumentDetails(http, engineName, promotedIds);
@ -116,14 +136,144 @@ export const CurationSuggestionLogic = kea<
flashAPIErrors(e);
}
},
acceptSuggestion: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const { suggestion } = values;
try {
const updatedSuggestion = await updateSuggestion(
http,
engineName,
suggestion!.query,
'applied'
);
setQueuedSuccessMessage(
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.successfullyAppliedMessage',
{ defaultMessage: 'Suggestion was succefully applied.' }
)
);
KibanaLogic.values.navigateToUrl(
generateEnginePath(ENGINE_CURATION_PATH, {
curationId: updatedSuggestion.curation_id,
})
);
} catch (e) {
flashAPIErrors(e);
}
},
acceptAndAutomateSuggestion: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const { suggestion } = values;
try {
const updatedSuggestion = await updateSuggestion(
http,
engineName,
suggestion!.query,
'automated'
);
setQueuedSuccessMessage(
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.successfullyAutomatedMessage',
{
defaultMessage:
'Suggestion was succefully applied and all future suggestions for the query "{query}" will be automatically applied.',
values: { query: suggestion!.query },
}
)
);
KibanaLogic.values.navigateToUrl(
generateEnginePath(ENGINE_CURATION_PATH, {
curationId: updatedSuggestion.curation_id,
})
);
} catch (e) {
flashAPIErrors(e);
}
},
rejectSuggestion: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const { suggestion } = values;
try {
await updateSuggestion(http, engineName, suggestion!.query, 'rejected');
setQueuedSuccessMessage(
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.successfullyRejectedMessage',
{
defaultMessage: 'Suggestion was succefully rejected.',
}
)
);
KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH));
} catch (e) {
flashAPIErrors(e);
}
},
rejectAndDisableSuggestion: async () => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
const { suggestion } = values;
try {
await updateSuggestion(http, engineName, suggestion!.query, 'disabled');
setQueuedSuccessMessage(
i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.successfullyDisabledMessage',
{
defaultMessage:
'Suggestion was succefully rejected and you will no longer receive suggestions for the query "{query}".',
values: { query: suggestion!.query },
}
)
);
KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH));
} catch (e) {
flashAPIErrors(e);
}
},
}),
});
const getSuggestions = async (
const updateSuggestion = async (
http: HttpSetup,
engineName: string,
query: string,
status: string
) => {
const response = await http.put<{ results: Array<CurationSuggestion | Error> }>(
`/internal/app_search/engines/${engineName}/search_relevance_suggestions`,
{
body: JSON.stringify([
{
query,
type: 'curation',
status,
},
]),
}
);
if (response.results[0].hasOwnProperty('error')) {
throw (response.results[0] as Error).error;
}
return response.results[0] as CurationSuggestion;
};
const getSuggestion = async (
http: HttpSetup,
engineName: string,
query: string
): Promise<CurationSuggestion> => {
): Promise<CurationSuggestion | undefined> => {
const response = await http.post(
`/internal/app_search/engines/${engineName}/search_relevance_suggestions/${query}`,
{
@ -140,6 +290,18 @@ const getSuggestions = async (
}
);
if (response.results.length < 1) {
const message = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.notFoundError',
{
defaultMessage: 'Could not find suggestion, it may have already been applied or rejected.',
}
);
setQueuedErrorMessage(message);
KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH));
return;
}
const suggestion = response.results[0] as CurationSuggestion;
return suggestion;
};