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" > +