diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx
index 8496be6223764..ea01ad974b9a1 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx
@@ -12,6 +12,7 @@ import React from 'react';
import { shallow } from 'enzyme';
+import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout';
import {
AnalyticsCards,
AnalyticsChart,
@@ -40,6 +41,7 @@ describe('Analytics overview', () => {
});
const wrapper = shallow();
+ expect(wrapper.find(SuggestedCurationsCallout)).toHaveLength(1);
expect(wrapper.find(AnalyticsCards)).toHaveLength(1);
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(AnalyticsSection)).toHaveLength(2);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx
index 0eef9b0c688c0..aa949a01f7d79 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx
@@ -25,6 +25,7 @@ import {
import { DataPanel } from '../../data_panel';
import { generateEnginePath } from '../../engine';
+import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components';
import {
@@ -60,6 +61,7 @@ export const Analytics: React.FC = () => {
return (
+
({
+ useLocalStorage: jest.fn(),
+}));
+
+import React from 'react';
+
+import { useLocation } from 'react-router-dom';
+
+import { shallow } from 'enzyme';
+
+import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui';
+
+import { EuiButtonTo } from '../../../../shared/react_router_helpers';
+import { useLocalStorage } from '../../../../shared/use_local_storage';
+
+import { SuggestionsCallout } from './suggestions_callout';
+
+const props = {
+ title: 'Title',
+ description: 'A description.',
+ buttonTo: '/suggestions',
+};
+
+const now = '2021-01-01T00:30:00Z';
+const tenMinutesAgo = '2021-01-01T00:20:00Z';
+const twentyMinutesAgo = '2021-01-01T00:10:00Z';
+
+describe('SuggestionsCallout', () => {
+ const mockSetLastDismissedTimestamp = jest.fn();
+ const setMockLastDismissedTimestamp = (lastDismissedTimestamp: string) => {
+ (useLocalStorage as jest.Mock).mockImplementation(() => [
+ lastDismissedTimestamp,
+ mockSetLastDismissedTimestamp,
+ ]);
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setMockLastDismissedTimestamp(tenMinutesAgo);
+ (useLocation as jest.Mock).mockImplementationOnce(() => ({
+ pathname: '/engines/some-engine',
+ }));
+ });
+
+ it('renders a callout with a link to the suggestions', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(EuiCallOut));
+ expect(wrapper.find(EuiButtonTo).prop('to')).toEqual('/suggestions');
+ });
+
+ it('is empty is it was updated before it was last dismissed', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.isEmptyRender()).toBe(true);
+ });
+
+ it('clicking the dismiss button updates the timestamp in local storage', () => {
+ jest.spyOn(global.Date.prototype, 'toISOString').mockImplementation(() => now);
+
+ const wrapper = shallow();
+ wrapper.find(EuiButtonEmpty).simulate('click');
+
+ expect(mockSetLastDismissedTimestamp).toHaveBeenCalledWith(now);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx
new file mode 100644
index 0000000000000..490e6323290f0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { useLocation } from 'react-router-dom';
+
+import {
+ EuiButtonEmpty,
+ EuiCallOut,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { LightbulbIcon } from '../../../../shared/icons';
+import { EuiButtonTo } from '../../../../shared/react_router_helpers';
+import { useLocalStorage } from '../../../../shared/use_local_storage';
+
+interface SuggestionsCalloutProps {
+ title: string;
+ description: string;
+ buttonTo: string;
+ lastUpdatedTimestamp: string; // ISO string like '2021-10-04T18:53:02.784Z'
+}
+
+export const SuggestionsCallout: React.FC = ({
+ title,
+ description,
+ buttonTo,
+ lastUpdatedTimestamp,
+}) => {
+ const { pathname } = useLocation();
+
+ const [lastDismissedTimestamp, setLastDismissedTimestamp] = useLocalStorage(
+ `suggestions-callout--${pathname}`,
+ new Date(0).toISOString()
+ );
+
+ if (new Date(lastDismissedTimestamp) >= new Date(lastUpdatedTimestamp)) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ {description}
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsCallout.reviewSuggestionsButtonLabel',
+ { defaultMessage: 'Review suggestions' }
+ )}
+
+
+
+ {
+ setLastDismissedTimestamp(new Date().toISOString());
+ }}
+ >
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsCallout.hideForNowLabel',
+ { defaultMessage: 'Hide this for now' }
+ )}
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx
index ad9f3bcd64e3e..32b9e91c8d6f2 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx
@@ -21,6 +21,7 @@ import { CurationLogic } from './curation_logic';
import { ManualCuration } from './manual_curation';
import { AddResultFlyout } from './results';
+import { SuggestedDocumentsCallout } from './suggested_documents_callout';
describe('ManualCuration', () => {
const values = {
@@ -50,6 +51,12 @@ describe('ManualCuration', () => {
]);
});
+ it('contains a suggested documents callout', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(SuggestedDocumentsCallout)).toHaveLength(1);
+ });
+
it('renders the add result flyout when open', () => {
setMockValues({ ...values, isFlyoutOpen: true });
const wrapper = shallow();
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx
index d50575535bf21..1482858801d2b 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx
@@ -21,6 +21,7 @@ import { CurationLogic } from './curation_logic';
import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents';
import { ActiveQuerySelect, ManageQueriesModal } from './queries';
import { AddResultLogic, AddResultFlyout } from './results';
+import { SuggestedDocumentsCallout } from './suggested_documents_callout';
export const ManualCuration: React.FC = () => {
const { curationId } = useParams() as { curationId: string };
@@ -46,6 +47,7 @@ export const ManualCuration: React.FC = () => {
}}
isLoading={dataLoading}
>
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.tsx
new file mode 100644
index 0000000000000..29418d09218f4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import '../../../__mocks__/engine_logic.mock';
+
+import { setMockValues } from '../../../../__mocks__/kea_logic';
+
+import React from 'react';
+
+import { shallow } from 'enzyme';
+import { set } from 'lodash/fp';
+
+import { SuggestionsCallout } from '../components/suggestions_callout';
+
+import { SuggestedDocumentsCallout } from './suggested_documents_callout';
+
+const MOCK_VALUES = {
+ // CurationLogic
+ curation: {
+ suggestion: {
+ status: 'pending',
+ updated_at: '2021-01-01T00:30:00Z',
+ },
+ queries: ['some query'],
+ },
+};
+
+describe('SuggestedDocumentsCallout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setMockValues(MOCK_VALUES);
+ });
+
+ it('renders', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.is(SuggestionsCallout));
+ });
+
+ it('is empty when the suggested is undefined', () => {
+ setMockValues({ ...MOCK_VALUES, curation: {} });
+
+ const wrapper = shallow();
+
+ expect(wrapper.isEmptyRender()).toBe(true);
+ });
+
+ it('is empty when curation status is not pending', () => {
+ const values = set('curation.suggestion.status', 'applied', MOCK_VALUES);
+ setMockValues(values);
+ const wrapper = shallow();
+
+ expect(wrapper.isEmptyRender()).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx
new file mode 100644
index 0000000000000..e443e77d76190
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 } from 'kea';
+
+import { i18n } from '@kbn/i18n';
+
+import { ENGINE_CURATION_SUGGESTION_PATH } from '../../../routes';
+import { generateEnginePath } from '../../engine';
+
+import { SuggestionsCallout } from '../components/suggestions_callout';
+
+import { CurationLogic } from '.';
+
+export const SuggestedDocumentsCallout: React.FC = () => {
+ const {
+ curation: { suggestion, queries },
+ } = useValues(CurationLogic);
+
+ if (typeof suggestion === 'undefined' || suggestion.status !== 'pending') {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
index 2c22a3addf63b..d4c652ab9c7a7 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
@@ -22,6 +22,21 @@ export interface Engine {
};
}
+interface CurationSuggestionDetails {
+ count: number;
+ pending: number;
+ applied: number;
+ automated: number;
+ rejected: number;
+ disabled: number;
+ last_updated: string;
+}
+
+interface SearchRelevanceSuggestionDetails {
+ count: number;
+ curation: CurationSuggestionDetails;
+}
+
export interface EngineDetails extends Engine {
created_at: string;
document_count: number;
@@ -38,6 +53,7 @@ export interface EngineDetails extends Engine {
isMeta: boolean;
engine_count?: number;
includedEngines?: EngineDetails[];
+ search_relevance_suggestions?: SearchRelevanceSuggestionDetails;
}
interface ResultField {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.tsx
new file mode 100644
index 0000000000000..38e57fa0483e6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.tsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import '../../../__mocks__/engine_logic.mock';
+
+import { setMockValues } from '../../../../__mocks__/kea_logic';
+
+import React from 'react';
+
+import { shallow } from 'enzyme';
+import { set } from 'lodash/fp';
+
+import { SuggestionsCallout } from '../../curations/components/suggestions_callout';
+
+import { SuggestedCurationsCallout } from './suggested_curations_callout';
+
+const MOCK_VALUES = {
+ // EngineLogic
+ engine: {
+ search_relevance_suggestions: {
+ curation: {
+ pending: 1,
+ },
+ },
+ },
+};
+
+describe('SuggestedCurationsCallout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setMockValues(MOCK_VALUES);
+ });
+
+ it('renders', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.is(SuggestionsCallout));
+ });
+
+ it('is empty when the suggestions are undefined', () => {
+ setMockValues({ ...MOCK_VALUES, engine: {} });
+
+ const wrapper = shallow();
+
+ expect(wrapper.isEmptyRender()).toBe(true);
+ });
+
+ it('is empty when no pending curations', () => {
+ const values = set('engine.search_relevance_suggestions.curation.pending', 0, MOCK_VALUES);
+ setMockValues(values);
+
+ const wrapper = shallow();
+
+ expect(wrapper.isEmptyRender()).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx
new file mode 100644
index 0000000000000..a7155b7d2b161
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 } from 'kea';
+
+import { i18n } from '@kbn/i18n';
+
+import { ENGINE_CURATIONS_PATH } from '../../../routes';
+import { SuggestionsCallout } from '../../curations/components/suggestions_callout';
+import { EngineLogic, generateEnginePath } from '../../engine';
+
+export const SuggestedCurationsCallout: React.FC = () => {
+ const {
+ engine: { search_relevance_suggestions: searchRelevanceSuggestions },
+ } = useValues(EngineLogic);
+
+ const pendingCount = searchRelevanceSuggestions?.curation.pending;
+
+ if (typeof searchRelevanceSuggestions === 'undefined' || pendingCount === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
index 14f182463d837..c80e5c2208c31 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
@@ -16,6 +16,7 @@ import { shallow } from 'enzyme';
import { getPageTitle } from '../../../test_helpers';
import { TotalStats, TotalCharts, RecentApiLogs } from './components';
+import { SuggestedCurationsCallout } from './components/suggested_curations_callout';
import { EngineOverviewMetrics } from './engine_overview_metrics';
describe('EngineOverviewMetrics', () => {
@@ -36,6 +37,7 @@ describe('EngineOverviewMetrics', () => {
const wrapper = shallow();
expect(getPageTitle(wrapper)).toEqual('Engine overview');
+ expect(wrapper.find(SuggestedCurationsCallout)).toHaveLength(1);
expect(wrapper.find(TotalStats)).toHaveLength(1);
expect(wrapper.find(TotalCharts)).toHaveLength(1);
expect(wrapper.find(RecentApiLogs)).toHaveLength(1);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
index 9c3a900dfe115..d245b293467f3 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
@@ -17,6 +17,8 @@ import { AppSearchPageTemplate } from '../layout';
import { TotalStats, TotalCharts, RecentApiLogs } from './components';
+import { SuggestedCurationsCallout } from './components/suggested_curations_callout';
+
import { EngineOverviewLogic } from './';
export const EngineOverviewMetrics: React.FC = () => {
@@ -38,6 +40,7 @@ export const EngineOverviewMetrics: React.FC = () => {
isLoading={dataLoading}
data-test-subj="EngineOverview"
>
+