[App Search] Add Curations overview table (#91565) (#92280)

* Add server API routes

* Add CurationsLogic file

- w/ listeners for overview table only
+ types/constants setup

* Add Curations overview + table

& update router to show view

* Test feedback

- test names, unnecessary beforeAll mocks

* i18n feedback

Co-authored-by: Constance <constancecchen@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-02-22 17:43:52 -05:00 committed by GitHub
parent 35cfcbef8f
commit eaeb0ec9f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 778 additions and 1 deletions

View file

@ -11,3 +11,20 @@ export const CURATIONS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.title',
{ defaultMessage: 'Curations' }
);
export const CURATIONS_OVERVIEW_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.overview.title',
{ defaultMessage: 'Curated results' }
);
export const CREATE_NEW_CURATION_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.create.title',
{ defaultMessage: 'Create new curation' }
);
export const DELETE_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(
'xpack.enterpriseSearch.appSearch.engine.curations.deleteSuccessMessage',
{ defaultMessage: 'Successfully removed curation.' }
);

View file

@ -0,0 +1,177 @@
/*
* 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 { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__';
import '../../__mocks__/engine_logic.mock';
import { nextTick } from '@kbn/test/jest';
import { DEFAULT_META } from '../../../shared/constants';
import { CurationsLogic } from './';
describe('CurationsLogic', () => {
const { mount } = new LogicMounter(CurationsLogic);
const { http } = mockHttpValues;
const { clearFlashMessages, setSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers;
const MOCK_CURATIONS_RESPONSE = {
meta: {
page: {
current: 1,
size: 10,
total_results: 1,
total_pages: 1,
},
},
results: [
{
id: 'some-curation-id',
last_updated: 'January 1, 1970 at 12:00PM',
queries: ['some query'],
promoted: [],
hidden: [],
organic: [],
},
],
};
const DEFAULT_VALUES = {
dataLoading: true,
curations: [],
meta: DEFAULT_META,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', () => {
mount();
expect(CurationsLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onCurationsLoad', () => {
it('should set curations and meta state, & dataLoading to false', () => {
mount();
CurationsLogic.actions.onCurationsLoad(MOCK_CURATIONS_RESPONSE);
expect(CurationsLogic.values).toEqual({
...DEFAULT_VALUES,
curations: MOCK_CURATIONS_RESPONSE.results,
meta: MOCK_CURATIONS_RESPONSE.meta,
dataLoading: false,
});
});
});
describe('onPaginate', () => {
it('should set meta.page.current state', () => {
mount();
CurationsLogic.actions.onPaginate(3);
expect(CurationsLogic.values).toEqual({
...DEFAULT_VALUES,
meta: { page: { ...DEFAULT_VALUES.meta.page, current: 3 } },
});
});
});
});
describe('listeners', () => {
describe('loadCurations', () => {
it('should set dataLoading state', () => {
mount({ dataLoading: false });
CurationsLogic.actions.loadCurations();
expect(CurationsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
});
});
it('should make an API call and set curations & meta state', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_CURATIONS_RESPONSE));
mount();
jest.spyOn(CurationsLogic.actions, 'onCurationsLoad');
CurationsLogic.actions.loadCurations();
await nextTick();
expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/curations', {
query: {
'page[current]': 1,
'page[size]': 10,
},
});
expect(CurationsLogic.actions.onCurationsLoad).toHaveBeenCalledWith(
MOCK_CURATIONS_RESPONSE
);
});
it('handles errors', async () => {
http.get.mockReturnValueOnce(Promise.reject('error'));
mount();
CurationsLogic.actions.loadCurations();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('deleteCurationSet', () => {
const confirmSpy = jest.spyOn(window, 'confirm');
beforeEach(() => {
confirmSpy.mockImplementation(jest.fn(() => true));
});
it('should make an API call and show a success message', async () => {
http.delete.mockReturnValueOnce(Promise.resolve({}));
mount();
jest.spyOn(CurationsLogic.actions, 'loadCurations');
CurationsLogic.actions.deleteCurationSet('some-curation-id');
expect(clearFlashMessages).toHaveBeenCalled();
await nextTick();
expect(http.delete).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/curations/some-curation-id'
);
expect(CurationsLogic.actions.loadCurations).toHaveBeenCalled();
expect(setSuccessMessage).toHaveBeenCalledWith('Successfully removed curation.');
});
it('handles errors', async () => {
http.delete.mockReturnValueOnce(Promise.reject('error'));
mount();
CurationsLogic.actions.deleteCurationSet('some-curation-id');
expect(clearFlashMessages).toHaveBeenCalled();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
it('does nothing if the user cancels the confirmation prompt', async () => {
confirmSpy.mockImplementationOnce(() => false);
mount();
CurationsLogic.actions.deleteCurationSet('some-curation-id');
expect(clearFlashMessages).toHaveBeenCalled();
await nextTick();
expect(http.delete).not.toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { Meta } from '../../../../../common/types';
import { DEFAULT_META } from '../../../shared/constants';
import {
clearFlashMessages,
setSuccessMessage,
flashAPIErrors,
} from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { EngineLogic } from '../engine';
import { DELETE_MESSAGE, SUCCESS_MESSAGE } from './constants';
import { Curation, CurationsAPIResponse } from './types';
interface CurationsValues {
dataLoading: boolean;
curations: Curation[];
meta: Meta;
}
interface CurationsActions {
onCurationsLoad(response: CurationsAPIResponse): CurationsAPIResponse;
onPaginate(newPageIndex: number): { newPageIndex: number };
loadCurations(): void;
deleteCurationSet(id: string): string;
}
export const CurationsLogic = kea<MakeLogicType<CurationsValues, CurationsActions>>({
path: ['enterprise_search', 'app_search', 'curations_logic'],
actions: () => ({
onCurationsLoad: ({ results, meta }) => ({ results, meta }),
onPaginate: (newPageIndex) => ({ newPageIndex }),
loadCurations: true,
deleteCurationSet: (id) => id,
}),
reducers: () => ({
dataLoading: [
true,
{
loadCurations: () => true,
onCurationsLoad: () => false,
},
],
curations: [
[],
{
onCurationsLoad: (_, { results }) => results,
},
],
meta: [
DEFAULT_META,
{
onCurationsLoad: (_, { meta }) => meta,
onPaginate: (state, { newPageIndex }) => {
const newState = { page: { ...state.page } };
newState.page.current = newPageIndex;
return newState;
},
},
],
}),
listeners: ({ actions, values }) => ({
loadCurations: async () => {
const { meta } = values;
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const response = await http.get(`/api/app_search/engines/${engineName}/curations`, {
query: {
'page[current]': meta.page.current,
'page[size]': meta.page.size,
},
});
actions.onCurationsLoad(response);
} catch (e) {
flashAPIErrors(e);
}
},
deleteCurationSet: async (id) => {
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
clearFlashMessages();
if (window.confirm(DELETE_MESSAGE)) {
try {
await http.delete(`/api/app_search/engines/${engineName}/curations/${id}`);
actions.loadCurations();
setSuccessMessage(SUCCESS_MESSAGE);
} catch (e) {
flashAPIErrors(e);
}
}
},
}),
});

View file

@ -20,6 +20,7 @@ import {
} from '../../routes';
import { CURATIONS_TITLE } from './constants';
import { Curations } from './views';
interface Props {
engineBreadcrumb: BreadcrumbTrail;
@ -31,7 +32,7 @@ export const CurationsRouter: React.FC<Props> = ({ engineBreadcrumb }) => {
<Switch>
<Route exact path={ENGINE_CURATIONS_PATH}>
<SetPageChrome trail={CURATIONS_BREADCRUMB} />
TODO: Curations overview
<Curations />
</Route>
<Route exact path={ENGINE_CURATIONS_NEW_PATH}>
<SetPageChrome trail={[...CURATIONS_BREADCRUMB, 'Create a curation']} />

View file

@ -7,3 +7,4 @@
export { CURATIONS_TITLE } from './constants';
export { CurationsRouter } from './curations_router';
export { CurationsLogic } from './curations_logic';

View file

@ -0,0 +1,22 @@
/*
* 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 { Meta } from '../../../../../common/types';
export interface Curation {
id: string;
last_updated: string;
queries: string[];
promoted: object[];
hidden: object[];
organic: object[];
}
export interface CurationsAPIResponse {
results: Curation[];
meta: Meta;
}

View file

@ -0,0 +1,172 @@
/*
* 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 { mockKibanaValues, setMockActions, setMockValues } from '../../../../__mocks__';
import '../../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow, mount, ReactWrapper } from 'enzyme';
import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui';
import { Loading } from '../../../../shared/loading';
import { Curations, CurationsTable } from './curations';
describe('Curations', () => {
const { navigateToUrl } = mockKibanaValues;
const values = {
dataLoading: false,
curations: [
{
id: 'cur-id-1',
last_updated: 'January 1, 1970 at 12:00PM',
queries: ['hiking'],
},
{
id: 'cur-id-2',
last_updated: 'January 2, 1970 at 12:00PM',
queries: ['mountains', 'valleys'],
},
],
meta: {
page: {
current: 1,
size: 10,
total_results: 2,
},
},
};
const actions = {
loadCurations: jest.fn(),
deleteCurationSet: jest.fn(),
onPaginate: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<Curations />);
expect(wrapper.find('h1').text()).toEqual('Curated results');
expect(wrapper.find(CurationsTable)).toHaveLength(1);
});
it('renders a loading component on page load', () => {
setMockValues({ ...values, dataLoading: true, curations: [] });
const wrapper = shallow(<Curations />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('calls loadCurations on page load', () => {
mount(<Curations />);
expect(actions.loadCurations).toHaveBeenCalledTimes(1);
});
describe('CurationsTable', () => {
it('renders an EuiEmptyPrompt if curations is empty', () => {
setMockValues({ ...values, curations: [] });
const wrapper = shallow(<CurationsTable />);
expect(wrapper.find(EuiBasicTable).prop('noItemsMessage').type).toEqual(EuiEmptyPrompt);
});
it('passes loading prop based on dataLoading', () => {
setMockValues({ ...values, dataLoading: true });
const wrapper = shallow(<CurationsTable />);
expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true);
});
describe('populated table render', () => {
let wrapper: ReactWrapper;
beforeAll(() => {
wrapper = mount(<CurationsTable />);
});
it('renders queries and last updated columns', () => {
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Queries');
expect(tableContent).toContain('hiking');
expect(tableContent).toContain('mountains, valleys');
expect(tableContent).toContain('Last updated');
expect(tableContent).toContain('January 1, 1970 at 12:00PM');
expect(tableContent).toContain('January 2, 1970 at 12:00PM');
});
it('renders queries with curation links', () => {
expect(
wrapper.find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]').first().prop('to')
).toEqual('/engines/some-engine/curations/cur-id-1');
expect(
wrapper.find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]').last().prop('to')
).toEqual('/engines/some-engine/curations/cur-id-2');
});
describe('action column', () => {
it('edit action navigates to curation link', () => {
wrapper.find('[data-test-subj="CurationsTableEditButton"]').first().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-id-1');
wrapper.find('[data-test-subj="CurationsTableEditButton"]').last().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-id-2');
});
it('delete action calls deleteCurationSet', () => {
wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').first().simulate('click');
expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-1');
wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').last().simulate('click');
expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-2');
});
});
});
describe('pagination', () => {
it('passes pagination props from meta.page', () => {
setMockValues({
...values,
meta: {
page: {
current: 5,
size: 10,
total_results: 50,
},
},
});
const wrapper = shallow(<CurationsTable />);
expect(wrapper.find(EuiBasicTable).prop('pagination')).toEqual({
pageIndex: 4,
pageSize: 10,
totalItemCount: 50,
hidePerPageOptions: true,
});
});
it('calls onPaginate on pagination change', () => {
const wrapper = shallow(<CurationsTable />);
wrapper.find(EuiBasicTable).simulate('change', { page: { index: 0 } });
expect(actions.onPaginate).toHaveBeenCalledWith(1);
});
});
});
});

View file

@ -0,0 +1,178 @@
/*
* 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, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import {
EuiPageHeader,
EuiPageHeaderSection,
EuiPageContent,
EuiTitle,
EuiBasicTable,
EuiBasicTableColumn,
EuiEmptyPrompt,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FlashMessages } from '../../../../shared/flash_messages';
import { KibanaLogic } from '../../../../shared/kibana';
import { Loading } from '../../../../shared/loading';
import { EuiButtonTo, EuiLinkTo } from '../../../../shared/react_router_helpers';
import { ENGINE_CURATIONS_NEW_PATH, ENGINE_CURATION_PATH } from '../../../routes';
import { generateEnginePath } from '../../engine';
import { CURATIONS_OVERVIEW_TITLE, CREATE_NEW_CURATION_TITLE } from '../constants';
import { CurationsLogic } from '../curations_logic';
import { Curation } from '../types';
export const Curations: React.FC = () => {
const { dataLoading, curations, meta } = useValues(CurationsLogic);
const { loadCurations } = useActions(CurationsLogic);
useEffect(() => {
loadCurations();
}, [meta.page.current]);
if (dataLoading && !curations.length) return <Loading />;
return (
<>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>{CURATIONS_OVERVIEW_TITLE}</h1>
</EuiTitle>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiButtonTo to={generateEnginePath(ENGINE_CURATIONS_NEW_PATH)} fill>
{CREATE_NEW_CURATION_TITLE}
</EuiButtonTo>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<FlashMessages />
<CurationsTable />
</EuiPageContent>
</>
);
};
export const CurationsTable: React.FC = () => {
const { dataLoading, curations, meta } = useValues(CurationsLogic);
const { onPaginate, deleteCurationSet } = useActions(CurationsLogic);
const columns: Array<EuiBasicTableColumn<Curation>> = [
{
field: 'queries',
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.column.queries',
{ defaultMessage: 'Queries' }
),
render: (queries: Curation['queries'], curation: Curation) => (
<EuiLinkTo
data-test-subj="CurationsTableQueriesLink"
to={generateEnginePath(ENGINE_CURATION_PATH, { curationId: curation.id })}
>
{queries.join(', ')}
</EuiLinkTo>
),
width: '40%',
truncateText: true,
mobileOptions: {
header: true,
// Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error
// @ts-ignore
enlarge: true,
width: '100%',
truncateText: false,
},
},
{
field: 'last_updated',
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.column.lastUpdated',
{ defaultMessage: 'Last updated' }
),
width: '30%',
dataType: 'string',
},
{
width: '120px',
actions: [
{
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.editAction',
{ defaultMessage: 'Edit' }
),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.editTooltip',
{ defaultMessage: 'Edit curation' }
),
type: 'icon',
icon: 'pencil',
color: 'primary',
onClick: (curation: Curation) => {
const { navigateToUrl } = KibanaLogic.values;
const url = generateEnginePath(ENGINE_CURATION_PATH, { curationId: curation.id });
navigateToUrl(url);
},
'data-test-subj': 'CurationsTableEditButton',
},
{
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.deleteAction',
{ defaultMessage: 'Delete' }
),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.deleteTooltip',
{ defaultMessage: 'Delete curation' }
),
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (curation: Curation) => deleteCurationSet(curation.id),
'data-test-subj': 'CurationsTableDeleteButton',
},
],
},
];
return (
<EuiBasicTable
columns={columns}
items={curations}
responsive
hasActions
loading={dataLoading}
noItemsMessage={
<EuiEmptyPrompt
iconType="pin"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.curations.table.empty.noCurationsTitle',
{ defaultMessage: 'No curations yet' }
)}
</h4>
}
/>
}
pagination={{
pageIndex: meta.page.current - 1,
pageSize: meta.page.size,
totalItemCount: meta.page.total_results,
hidePerPageOptions: true,
}}
onChange={({ page }: { page: { index: number } }) => {
const { index } = page;
onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0
}}
/>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { Curations } from './curations';

View file

@ -10,6 +10,69 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks_
import { registerCurationsRoutes } from './curations';
describe('curations routes', () => {
describe('GET /api/app_search/engines/{engineName}/curations', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/app_search/engines/{engineName}/curations',
});
registerCurationsRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:engineName/curations/collection',
});
});
describe('validates', () => {
it('with pagination query params', () => {
const request = {
query: {
'page[current]': 1,
'page[size]': 10,
},
};
mockRouter.shouldValidate(request);
});
it('missing query params', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
});
});
describe('DELETE /api/app_search/engines/{engineName}/curations/{curationId}', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'delete',
path: '/api/app_search/engines/{engineName}/curations/{curationId}',
});
registerCurationsRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:engineName/curations/:curationId',
});
});
});
describe('GET /api/app_search/engines/{engineName}/curations/find_or_create', () => {
let mockRouter: MockRouter;

View file

@ -13,6 +13,39 @@ export function registerCurationsRoutes({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/app_search/engines/{engineName}/curations',
validate: {
params: schema.object({
engineName: schema.string(),
}),
query: schema.object({
'page[current]': schema.number(),
'page[size]': schema.number(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:engineName/curations/collection',
})
);
router.delete(
{
path: '/api/app_search/engines/{engineName}/curations/{curationId}',
validate: {
params: schema.object({
engineName: schema.string(),
curationId: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:engineName/curations/:curationId',
})
);
router.get(
{
path: '/api/app_search/engines/{engineName}/curations/find_or_create',