[App Search] Set up Curations routes & complete 'Edit Query' action in Analytics tables (#91052)

* Set up Curations routes

* Update EngineRouter/Nav with Curations

* Set up Curations find_or_create API

* [bug] Fix view action not working correctly for "" query

* Add Edit query action
- to call find_or_create curation API & navigate to curation page

+ fix copy string, only just noticed this :doh:

* Add/update unit tests for action column

- Refactor out into a single shared test helper file that both AnalyticsTable and RecentQueriesTable simply calls & runs (instead of copying and pasting the same tests twice into 2 diff files)
- note: test file can't be `.test.tsx` or Jest tries to automatically run it, which we don't want

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Constance 2021-02-16 10:00:29 -08:00 committed by GitHub
parent a1a9769f83
commit f2e4cce0a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 297 additions and 42 deletions

View file

@ -5,18 +5,18 @@
* 2.0.
*/
import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__';
import { mountWithIntl } from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { runActionColumnTests } from './shared_columns_tests';
import { AnalyticsTable } from './';
describe('AnalyticsTable', () => {
const { navigateToUrl } = mockKibanaValues;
const items = [
{
key: 'some search',
@ -69,18 +69,9 @@ describe('AnalyticsTable', () => {
expect(tableContent).toContain('0');
});
it('renders an action column', () => {
describe('renders an action column', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} />);
const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first();
const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first();
viewQuery.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
editQuery.simulate('click');
// TODO
runActionColumnTests(wrapper);
});
it('renders an empty prompt if no items are passed', () => {

View file

@ -5,18 +5,18 @@
* 2.0.
*/
import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__';
import { mountWithIntl } from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { runActionColumnTests } from './shared_columns_tests';
import { RecentQueriesTable } from './';
describe('RecentQueriesTable', () => {
const { navigateToUrl } = mockKibanaValues;
const items = [
{
query_string: 'some search',
@ -63,18 +63,9 @@ describe('RecentQueriesTable', () => {
expect(tableContent).toContain('3');
});
it('renders an action column', () => {
describe('renders an action column', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={items} />);
const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first();
const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first();
viewQuery.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
editQuery.simulate('click');
// TODO
runActionColumnTests(wrapper);
});
it('renders an empty prompt if no items are passed', () => {

View file

@ -9,10 +9,12 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { flashAPIErrors } from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes';
import { generateEnginePath } from '../../../engine';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH, ENGINE_CURATION_PATH } from '../../../../routes';
import { generateEnginePath, EngineLogic } from '../../../engine';
import { Query, RecentQuery } from '../../types';
import { InlineTagsList } from './inline_tags_list';
@ -63,7 +65,7 @@ export const ACTIONS_COLUMN = {
onClick: (item: Query | RecentQuery) => {
const { navigateToUrl } = KibanaLogic.values;
const query = (item as Query).key || (item as RecentQuery).query_string;
const query = (item as Query).key || (item as RecentQuery).query_string || '""';
navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query }));
},
'data-test-subj': 'AnalyticsTableViewQueryButton',
@ -74,12 +76,25 @@ export const ACTIONS_COLUMN = {
}),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip',
{ defaultMessage: 'Edit query analytics' }
{ defaultMessage: 'Edit query' }
),
type: 'icon',
icon: 'pencil',
onClick: () => {
// TODO: CurationsLogic
onClick: async (item: Query | RecentQuery) => {
const { http } = HttpLogic.values;
const { navigateToUrl } = KibanaLogic.values;
const { engineName } = EngineLogic.values;
try {
const query = (item as Query).key || (item as RecentQuery).query_string || '""';
const response = await http.get(
`/api/app_search/engines/${engineName}/curations/find_or_create`,
{ query: { query } }
);
navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id }));
} catch (e) {
flashAPIErrors(e);
}
},
'data-test-subj': 'AnalyticsTableEditQueryButton',
},

View file

@ -0,0 +1,82 @@
/*
* 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 {
mockHttpValues,
mockKibanaValues,
mockFlashMessageHelpers,
} from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import { ReactWrapper } from 'enzyme';
import { nextTick } from '@kbn/test/jest';
export const runActionColumnTests = (wrapper: ReactWrapper) => {
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const { flashAPIErrors } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
});
describe('view action', () => {
it('navigates to the query detail view', () => {
wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
});
it('falls back to "" for the empty query', () => {
wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').last().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/%22%22'
);
});
});
describe('edit action', () => {
it('calls the find_or_create curation API, then navigates the user to the curation', async () => {
http.get.mockReturnValue(Promise.resolve({ id: 'cur-123456789' }));
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click');
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/curations/find_or_create',
{
query: { query: 'some search' },
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-123456789');
});
it('falls back to "" for the empty query', async () => {
http.get.mockReturnValue(Promise.resolve({ id: 'cur-987654321' }));
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').last().simulate('click');
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/some-engine/curations/find_or_create',
{
query: { query: '""' },
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-987654321');
});
it('handles API errors', async () => {
http.get.mockReturnValue(Promise.reject());
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click');
await nextTick();
expect(flashAPIErrors).toHaveBeenCalled();
});
});
};

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 React from 'react';
import { Route, Switch } from 'react-router-dom';
import { shallow } from 'enzyme';
import { CurationsRouter } from './';
describe('CurationsRouter', () => {
it('renders', () => {
const wrapper = shallow(<CurationsRouter engineBreadcrumb={['Engines', 'some-engine']} />);
expect(wrapper.find(Switch)).toHaveLength(1);
expect(wrapper.find(Route)).toHaveLength(5);
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { Route, Switch } from 'react-router-dom';
import { APP_SEARCH_PLUGIN } from '../../../../../common/constants';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs';
import { NotFound } from '../../../shared/not_found';
import {
ENGINE_CURATIONS_PATH,
ENGINE_CURATIONS_NEW_PATH,
ENGINE_CURATION_PATH,
ENGINE_CURATION_ADD_RESULT_PATH,
} from '../../routes';
import { CURATIONS_TITLE } from './constants';
interface Props {
engineBreadcrumb: BreadcrumbTrail;
}
export const CurationsRouter: React.FC<Props> = ({ engineBreadcrumb }) => {
const CURATIONS_BREADCRUMB = [...engineBreadcrumb, CURATIONS_TITLE];
return (
<Switch>
<Route exact path={ENGINE_CURATIONS_PATH}>
<SetPageChrome trail={CURATIONS_BREADCRUMB} />
TODO: Curations overview
</Route>
<Route exact path={ENGINE_CURATIONS_NEW_PATH}>
<SetPageChrome trail={[...CURATIONS_BREADCRUMB, 'Create a curation']} />
TODO: Curation creation view
</Route>
<Route exact path={ENGINE_CURATION_PATH}>
<SetPageChrome trail={[...CURATIONS_BREADCRUMB, 'curation queries']} />
TODO: Curation view (+ show a NotFound view if ID is invalid)
</Route>
<Route exact path={ENGINE_CURATION_ADD_RESULT_PATH}>
<SetPageChrome
trail={[...CURATIONS_BREADCRUMB, 'curation queries', 'add result manually']}
/>
TODO: Curation Add Result view
</Route>
<Route>
<NotFound breadcrumbs={CURATIONS_BREADCRUMB} product={APP_SEARCH_PLUGIN} />
</Route>
</Switch>
);
};

View file

@ -6,3 +6,4 @@
*/
export { CURATIONS_TITLE } from './constants';
export { CurationsRouter } from './curations_router';

View file

@ -220,8 +220,8 @@ export const EngineNav: React.FC = () => {
)}
{canManageEngineCurations && (
<SideNavLink
isExternal
to={getAppSearchUrl(generateEnginePath(ENGINE_CURATIONS_PATH))}
to={generateEnginePath(ENGINE_CURATIONS_PATH)}
shouldShowActiveForSubroutes
data-test-subj="EngineCurationsLink"
>
{CURATIONS_TITLE}

View file

@ -17,6 +17,7 @@ import { shallow } from 'enzyme';
import { Loading } from '../../../shared/loading';
import { AnalyticsRouter } from '../analytics';
import { CurationsRouter } from '../curations';
import { EngineOverview } from '../engine_overview';
import { RelevanceTuning } from '../relevance_tuning';
@ -97,7 +98,14 @@ describe('EngineRouter', () => {
expect(wrapper.find(AnalyticsRouter)).toHaveLength(1);
});
it('renders an relevance tuning view', () => {
it('renders a curations view', () => {
setMockValues({ ...values, myRole: { canManageEngineCurations: true } });
const wrapper = shallow(<EngineRouter />);
expect(wrapper.find(CurationsRouter)).toHaveLength(1);
});
it('renders a relevance tuning view', () => {
setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } });
const wrapper = shallow(<EngineRouter />);

View file

@ -28,12 +28,13 @@ import {
// META_ENGINE_SOURCE_ENGINES_PATH,
ENGINE_RELEVANCE_TUNING_PATH,
// ENGINE_SYNONYMS_PATH,
// ENGINE_CURATIONS_PATH,
ENGINE_CURATIONS_PATH,
// ENGINE_RESULT_SETTINGS_PATH,
// ENGINE_SEARCH_UI_PATH,
// ENGINE_API_LOGS_PATH,
} from '../../routes';
import { AnalyticsRouter } from '../analytics';
import { CurationsRouter } from '../curations';
import { DocumentDetail, Documents } from '../documents';
import { OVERVIEW_TITLE } from '../engine_overview';
import { EngineOverview } from '../engine_overview';
@ -46,13 +47,13 @@ export const EngineRouter: React.FC = () => {
const {
myRole: {
canViewEngineAnalytics,
canManageEngineRelevanceTuning,
// canViewEngineDocuments,
// canViewEngineSchema,
// canViewEngineCrawler,
// canViewMetaEngineSourceEngines,
canManageEngineRelevanceTuning,
// canManageEngineSynonyms,
// canManageEngineCurations,
canManageEngineCurations,
// canManageEngineResultSettings,
// canManageEngineSearchUi,
// canViewEngineApiLogs,
@ -97,6 +98,11 @@ export const EngineRouter: React.FC = () => {
<Route path={ENGINE_DOCUMENTS_PATH}>
<Documents engineBreadcrumb={engineBreadcrumb} />
</Route>
{canManageEngineCurations && (
<Route path={ENGINE_CURATIONS_PATH}>
<CurationsRouter engineBreadcrumb={engineBreadcrumb} />
</Route>
)}
{canManageEngineRelevanceTuning && (
<Route path={ENGINE_RELEVANCE_TUNING_PATH}>
<RelevanceTuning engineBreadcrumb={engineBreadcrumb} />

View file

@ -44,9 +44,12 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`;
export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`;
export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`;
export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`;
// TODO: Curations sub-pages
export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`;
export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`;
export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`;
export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`;
export const ENGINE_CURATION_ADD_RESULT_PATH = `${ENGINE_CURATIONS_PATH}/:curationId/add_result`;
export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`;
export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api-logs`;

View file

@ -0,0 +1,47 @@
/*
* 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
import { registerCurationsRoutes } from './curations';
describe('curations routes', () => {
describe('GET /api/app_search/engines/{engineName}/curations/find_or_create', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/app_search/engines/{engineName}/curations/find_or_create',
});
registerCurationsRoutes({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/:engineName/curations/find_or_create',
});
});
describe('validates', () => {
it('required query param', () => {
const request = { query: { query: 'some query' } };
mockRouter.shouldValidate(request);
});
it('missing query', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
});
});
});

View file

@ -0,0 +1,32 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../plugin';
export function registerCurationsRoutes({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/app_search/engines/{engineName}/curations/find_or_create',
validate: {
params: schema.object({
engineName: schema.string(),
}),
query: schema.object({
query: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/as/engines/:engineName/curations/find_or_create',
})
);
}

View file

@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin';
import { registerAnalyticsRoutes } from './analytics';
import { registerCredentialsRoutes } from './credentials';
import { registerCurationsRoutes } from './curations';
import { registerDocumentsRoutes, registerDocumentRoutes } from './documents';
import { registerEnginesRoutes } from './engines';
import { registerSearchSettingsRoutes } from './search_settings';
@ -21,5 +22,6 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => {
registerAnalyticsRoutes(dependencies);
registerDocumentsRoutes(dependencies);
registerDocumentRoutes(dependencies);
registerCurationsRoutes(dependencies);
registerSearchSettingsRoutes(dependencies);
};